@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,424 @@
|
|
|
1
|
+
import { createHmac } from 'crypto';
|
|
2
|
+
export function registerProductsListRoutes(app, deps) {
|
|
3
|
+
const { db, getUser, VALID_PRODUCT_TYPES, RAW_MODE_MIN_TRUST, getAgentTrustCached, VALID_SORTS, PRODUCT_LIMITS, TRENDING_SCORE_EXPR, findProductsByAlias, decodeProductCursor, encodeProductCursor, MASTER_SEED, formatProductForAgent } = deps;
|
|
4
|
+
app.get('/api/products', (req, res) => {
|
|
5
|
+
const { q = '', category, max_price, min_return_days, max_handling_hours, has_sales, ship_to, mode: modeRaw = 'pwa', sort: sortRaw, cursor, limit: limitRaw, seller_id, product_type: productTypeRaw, fuzzy: fuzzyRaw, since_days: sinceDaysRaw } = req.query;
|
|
6
|
+
let mode = modeRaw === 'agent' ? 'agent' : (modeRaw === 'raw' ? 'raw' : 'pwa');
|
|
7
|
+
// fuzzy=true → 发现页用,模糊 LIKE;否则用协议级 alias 精确匹配(智能下单页)
|
|
8
|
+
const isFuzzy = fuzzyRaw === 'true' || fuzzyRaw === '1';
|
|
9
|
+
// product_type 过滤:pwa 默认 retail(不混 B2B/数字/服务);agent/raw 不默认过滤
|
|
10
|
+
let productTypeFilter = null;
|
|
11
|
+
if (typeof productTypeRaw === 'string' && productTypeRaw && VALID_PRODUCT_TYPES.has(productTypeRaw)) {
|
|
12
|
+
productTypeFilter = productTypeRaw;
|
|
13
|
+
}
|
|
14
|
+
else if (productTypeRaw === 'all') {
|
|
15
|
+
productTypeFilter = null;
|
|
16
|
+
}
|
|
17
|
+
else if (mode === 'pwa') {
|
|
18
|
+
productTypeFilter = 'retail';
|
|
19
|
+
}
|
|
20
|
+
const me = getUser(req); // 可选 auth — 登录用户应用黑名单过滤
|
|
21
|
+
// raw mode 鉴权(trust_score ≥ RAW_MODE_MIN_TRUST)
|
|
22
|
+
if (mode === 'raw') {
|
|
23
|
+
const key = req.headers.authorization?.replace('Bearer ', '') ?? '';
|
|
24
|
+
if (!key)
|
|
25
|
+
return void res.status(401).json({ error: 'raw_mode_requires_auth', min_trust: RAW_MODE_MIN_TRUST });
|
|
26
|
+
const t = getAgentTrustCached(key);
|
|
27
|
+
if (!t || t.trust_score < RAW_MODE_MIN_TRUST) {
|
|
28
|
+
return void res.status(403).json({
|
|
29
|
+
error: 'raw_mode_trust_insufficient',
|
|
30
|
+
your_trust: t?.trust_score ?? 0,
|
|
31
|
+
min_trust: RAW_MODE_MIN_TRUST,
|
|
32
|
+
hint: '提升 trust_score 后可使用 raw mode:见 /api/agents/me/reputation',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// 排序模式
|
|
37
|
+
let sort = (typeof sortRaw === 'string' && VALID_SORTS.has(sortRaw)) ? sortRaw : 'trending';
|
|
38
|
+
// #977:新品发现 (has_sales=false) trending/recommended 转用卖家维度
|
|
39
|
+
const isNewArrivalsCtx = has_sales === 'false';
|
|
40
|
+
// limit
|
|
41
|
+
const cap = PRODUCT_LIMITS[mode];
|
|
42
|
+
let lim = Number(limitRaw);
|
|
43
|
+
if (!Number.isFinite(lim) || lim <= 0)
|
|
44
|
+
lim = mode === 'pwa' ? 30 : (mode === 'raw' ? 100 : 50);
|
|
45
|
+
if (lim > cap)
|
|
46
|
+
lim = cap;
|
|
47
|
+
// 内层 SELECT:把 score 计算出来,外层用它过滤 + 排序
|
|
48
|
+
// recommend_count = 已购买的买家中 4 星+ 评价的去重数(一个买家只能推荐一次)
|
|
49
|
+
const innerSelect = `SELECT p.*, u.name as seller_name, u.created_at as seller_created_at,
|
|
50
|
+
COALESCE(rs.total_points, 0) as rep_points, COALESCE(rs.level, 'new') as rep_level,
|
|
51
|
+
COALESCE(rs.transactions_done, 0) as seller_tx_count,
|
|
52
|
+
pc.seasonal_months as seasonal_months,
|
|
53
|
+
(SELECT COUNT(1) FROM orders o WHERE o.product_id = p.id AND o.status = 'completed') as sales_count,
|
|
54
|
+
(SELECT COUNT(DISTINCT buyer_id) FROM order_ratings r WHERE r.product_id = p.id AND r.stars >= 4) as recommend_count,
|
|
55
|
+
(SELECT COUNT(*) FROM dispute_cases dc WHERE dc.seller_id = p.seller_id) as seller_dispute_count,
|
|
56
|
+
-- 卖家仲裁胜率:无案件视为 0.8 中性(未经检验);有案件按真实比率
|
|
57
|
+
CASE
|
|
58
|
+
WHEN (SELECT COUNT(*) FROM dispute_cases WHERE seller_id = p.seller_id) = 0 THEN 0.8
|
|
59
|
+
ELSE CAST((SELECT COUNT(*) FROM dispute_cases WHERE seller_id = p.seller_id AND winner = 'seller') AS REAL) /
|
|
60
|
+
(SELECT COUNT(*) FROM dispute_cases WHERE seller_id = p.seller_id)
|
|
61
|
+
END as seller_win_rate,
|
|
62
|
+
-- #977:卖家维度聚合 — 新品发现页按卖家排序
|
|
63
|
+
(SELECT COUNT(DISTINCT r2.buyer_id) FROM order_ratings r2
|
|
64
|
+
JOIN products p2 ON p2.id = r2.product_id
|
|
65
|
+
WHERE p2.seller_id = p.seller_id AND r2.stars >= 4) as seller_recommend_count,
|
|
66
|
+
-- #982:测评免单标记
|
|
67
|
+
(SELECT quota_total - quota_claimed FROM product_trial_campaigns
|
|
68
|
+
WHERE product_id = p.id AND status = 'active') as trial_quota_remaining,
|
|
69
|
+
${TRENDING_SCORE_EXPR} as trending_score`;
|
|
70
|
+
let where = `WHERE p.status = 'active' AND p.stock > 0
|
|
71
|
+
AND COALESCE(u.listing_paused, 0) = 0
|
|
72
|
+
AND NOT (
|
|
73
|
+
EXISTS (SELECT 1 FROM product_external_links pel WHERE pel.product_id = p.id AND pel.revoked = 1)
|
|
74
|
+
AND NOT EXISTS (SELECT 1 FROM product_external_links pel WHERE pel.product_id = p.id AND pel.verified = 1 AND (pel.revoked IS NULL OR pel.revoked = 0))
|
|
75
|
+
)`;
|
|
76
|
+
const params = [];
|
|
77
|
+
if (me?.id) {
|
|
78
|
+
where += ` AND u.id NOT IN (SELECT blocked_id FROM user_blocklist WHERE blocker_id = ?)`;
|
|
79
|
+
params.push(me.id);
|
|
80
|
+
}
|
|
81
|
+
// fuzzy=true 含义升级为 strict → fuzzy 自动回退
|
|
82
|
+
let matchMode = 'none';
|
|
83
|
+
if (q) {
|
|
84
|
+
const ids = [...findProductsByAlias(String(q))];
|
|
85
|
+
if (ids.length > 0) {
|
|
86
|
+
where += ` AND p.id IN (${ids.map(() => '?').join(',')})`;
|
|
87
|
+
params.push(...ids);
|
|
88
|
+
matchMode = 'strict';
|
|
89
|
+
}
|
|
90
|
+
else if (isFuzzy) {
|
|
91
|
+
// strict 无果 → fallback 模糊匹配
|
|
92
|
+
// P0 fix: 转义用户输入里的 LIKE 通配符 % _ \ 防止 "%abc"/"a_b" 过宽
|
|
93
|
+
const qStr = String(q).trim().slice(0, 100).replace(/[\\%_]/g, '\\$&');
|
|
94
|
+
where += ` AND (p.title LIKE ? ESCAPE '\\' OR p.description LIKE ? ESCAPE '\\' OR p.category LIKE ? ESCAPE '\\' OR COALESCE(p.brand,'') LIKE ? ESCAPE '\\')`;
|
|
95
|
+
params.push(`%${qStr}%`, `%${qStr}%`, `%${qStr}%`, `%${qStr}%`);
|
|
96
|
+
matchMode = 'fuzzy';
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
// strict 无果 + 不允许 fuzzy → 协议契约 0 命中
|
|
100
|
+
where += ` AND 1 = 0`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (category) {
|
|
104
|
+
where += ` AND p.category = ?`;
|
|
105
|
+
params.push(category);
|
|
106
|
+
}
|
|
107
|
+
if (max_price) {
|
|
108
|
+
where += ` AND p.price <= ?`;
|
|
109
|
+
params.push(Number(max_price));
|
|
110
|
+
}
|
|
111
|
+
if (min_return_days) {
|
|
112
|
+
where += ` AND p.return_days >= ?`;
|
|
113
|
+
params.push(Number(min_return_days));
|
|
114
|
+
}
|
|
115
|
+
if (max_handling_hours) {
|
|
116
|
+
where += ` AND p.handling_hours <= ?`;
|
|
117
|
+
params.push(Number(max_handling_hours));
|
|
118
|
+
}
|
|
119
|
+
if (seller_id) {
|
|
120
|
+
where += ` AND p.seller_id = ?`;
|
|
121
|
+
params.push(String(seller_id));
|
|
122
|
+
}
|
|
123
|
+
if (productTypeFilter) {
|
|
124
|
+
where += ` AND COALESCE(p.product_type, 'retail') = ?`;
|
|
125
|
+
params.push(productTypeFilter);
|
|
126
|
+
}
|
|
127
|
+
if (ship_to && typeof ship_to === 'string' && ship_to.trim()) {
|
|
128
|
+
const target = ship_to.trim();
|
|
129
|
+
where += ` AND (p.ship_regions = '全国' OR p.ship_regions LIKE ? OR p.ship_regions LIKE ? OR p.ship_regions LIKE ? OR p.ship_regions = ?)`;
|
|
130
|
+
params.push(`${target},%`, `%,${target},%`, `%,${target}`, target);
|
|
131
|
+
where += ` AND (p.excluded_regions IS NULL OR p.excluded_regions = '' OR (p.excluded_regions NOT LIKE ? AND p.excluded_regions NOT LIKE ? AND p.excluded_regions NOT LIKE ? AND p.excluded_regions != ?))`;
|
|
132
|
+
params.push(`${target},%`, `%,${target},%`, `%,${target}`, target);
|
|
133
|
+
}
|
|
134
|
+
// #987:has_trial=true 优先级高于 has_sales=false
|
|
135
|
+
if (has_sales === 'true') {
|
|
136
|
+
where += ` AND EXISTS (SELECT 1 FROM orders WHERE product_id = p.id AND status = 'completed')`;
|
|
137
|
+
}
|
|
138
|
+
else if (has_sales === 'false' && req.query.has_trial !== 'true') {
|
|
139
|
+
where += ` AND NOT EXISTS (SELECT 1 FROM orders WHERE product_id = p.id AND status = 'completed')`;
|
|
140
|
+
}
|
|
141
|
+
// P1-4:新品发现时段过滤
|
|
142
|
+
if (sinceDaysRaw && typeof sinceDaysRaw === 'string') {
|
|
143
|
+
const d = Number(sinceDaysRaw);
|
|
144
|
+
if (Number.isFinite(d) && d > 0 && d <= 365) {
|
|
145
|
+
where += ` AND p.created_at >= datetime('now', '-' || ? || ' days')`;
|
|
146
|
+
params.push(d);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// #987:has_trial=true 只返回开了测评免单 + 还有名额的商品
|
|
150
|
+
if (req.query.has_trial === 'true') {
|
|
151
|
+
where += ` AND EXISTS (SELECT 1 FROM product_trial_campaigns ptc WHERE ptc.product_id = p.id AND ptc.status='active' AND ptc.quota_claimed < ptc.quota_total)`;
|
|
152
|
+
}
|
|
153
|
+
const baseFrom = `FROM products p JOIN users u ON p.seller_id = u.id LEFT JOIN reputation_scores rs ON rs.user_id = p.seller_id LEFT JOIN product_categories pc ON pc.id = p.category_id ${where}`;
|
|
154
|
+
// 排序 + cursor
|
|
155
|
+
// Sprint 5-D: claim_loss_count ASC 全 sort 最高优先级
|
|
156
|
+
let orderBy = '';
|
|
157
|
+
let cursorClause = '';
|
|
158
|
+
const cursorParams = [];
|
|
159
|
+
if (sort === 'trending') {
|
|
160
|
+
if (isNewArrivalsCtx) {
|
|
161
|
+
// 新品页:按卖家近期交易量降序
|
|
162
|
+
orderBy = ` ORDER BY COALESCE(claim_loss_count, 0) ASC, seller_tx_count DESC, created_at DESC, id DESC`;
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
orderBy = ` ORDER BY COALESCE(claim_loss_count, 0) ASC, trending_score DESC, id DESC`;
|
|
166
|
+
if (typeof cursor === 'string') {
|
|
167
|
+
const c = decodeProductCursor(cursor);
|
|
168
|
+
if (c) {
|
|
169
|
+
cursorClause = ` AND (trending_score < ? OR (trending_score = ? AND id < ?))`;
|
|
170
|
+
cursorParams.push(c.score, c.score, c.id);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
else if (sort === 'newest') {
|
|
176
|
+
orderBy = ` ORDER BY COALESCE(claim_loss_count, 0) ASC, created_at DESC, id DESC`;
|
|
177
|
+
if (typeof cursor === 'string') {
|
|
178
|
+
const c = decodeProductCursor(cursor);
|
|
179
|
+
if (c) {
|
|
180
|
+
cursorClause = ` AND (julianday(created_at) < ? OR (julianday(created_at) = ? AND id < ?))`;
|
|
181
|
+
cursorParams.push(c.score, c.score, c.id);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else if (sort === 'rating') {
|
|
186
|
+
orderBy = ` ORDER BY COALESCE(claim_loss_count, 0) ASC, rep_points DESC, id DESC`;
|
|
187
|
+
}
|
|
188
|
+
else if (sort === 'price_asc') {
|
|
189
|
+
orderBy = ` ORDER BY COALESCE(claim_loss_count, 0) ASC, price ASC, id ASC`;
|
|
190
|
+
}
|
|
191
|
+
else if (sort === 'price_desc') {
|
|
192
|
+
orderBy = ` ORDER BY COALESCE(claim_loss_count, 0) ASC, price DESC, id DESC`;
|
|
193
|
+
}
|
|
194
|
+
else if (sort === 'random') {
|
|
195
|
+
orderBy = ` ORDER BY COALESCE(claim_loss_count, 0) ASC, RANDOM()`;
|
|
196
|
+
}
|
|
197
|
+
else if (sort === 'recommended') {
|
|
198
|
+
if (isNewArrivalsCtx) {
|
|
199
|
+
orderBy = ` ORDER BY COALESCE(claim_loss_count, 0) ASC, seller_recommend_count DESC, seller_tx_count DESC, id DESC`;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
orderBy = ` ORDER BY COALESCE(claim_loss_count, 0) ASC, recommend_count DESC, sales_count DESC, id DESC`;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
else if (sort === 'seller_win_rate') {
|
|
206
|
+
orderBy = ` ORDER BY COALESCE(claim_loss_count, 0) ASC, seller_win_rate DESC, seller_dispute_count DESC, id DESC`;
|
|
207
|
+
}
|
|
208
|
+
// 多取候选 buffer 用于 jitter 排序 + 单卖家上限
|
|
209
|
+
const applySellerCap = !seller_id;
|
|
210
|
+
const SELLER_CAP = 3;
|
|
211
|
+
const buffer = (sort === 'trending' || applySellerCap) ? Math.min(lim * 3, lim + 30) : lim;
|
|
212
|
+
const sql = `SELECT * FROM (${innerSelect} ${baseFrom}) WHERE 1=1 ${cursorClause} ${orderBy} LIMIT ?`;
|
|
213
|
+
const finalParams = [...params, ...cursorParams, buffer];
|
|
214
|
+
let candidates;
|
|
215
|
+
try {
|
|
216
|
+
candidates = db.prepare(sql).all(...finalParams);
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
console.error('[/api/products] sql error:', e, '\nSQL:', sql);
|
|
220
|
+
return void res.status(500).json({ error: 'query_failed' });
|
|
221
|
+
}
|
|
222
|
+
// 单卖家 slot 上限
|
|
223
|
+
let filtered = candidates;
|
|
224
|
+
if (applySellerCap) {
|
|
225
|
+
const perSeller = new Map();
|
|
226
|
+
filtered = [];
|
|
227
|
+
for (const c of candidates) {
|
|
228
|
+
const sid = String(c.seller_id);
|
|
229
|
+
const n = perSeller.get(sid) || 0;
|
|
230
|
+
if (n >= SELLER_CAP)
|
|
231
|
+
continue;
|
|
232
|
+
perSeller.set(sid, n + 1);
|
|
233
|
+
filtered.push(c);
|
|
234
|
+
if (filtered.length >= lim + 10)
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
// 温和 jitter:仅 trending + pwa
|
|
239
|
+
let rows = filtered;
|
|
240
|
+
if (sort === 'trending' && mode === 'pwa' && rows.length > 1) {
|
|
241
|
+
const jittered = rows.map(r => ({ r, k: (Number(r.trending_score) || 0) + (Math.random() - 0.5) }));
|
|
242
|
+
jittered.sort((a, b) => b.k - a.k);
|
|
243
|
+
rows = jittered.map(x => x.r);
|
|
244
|
+
}
|
|
245
|
+
// 新卖家 slot 保护(trending only, ≤90d)
|
|
246
|
+
const NEW_SELLER_SLOTS = 2;
|
|
247
|
+
const NEW_SELLER_AGE_MS = 90 * 86400_000;
|
|
248
|
+
if (sort === 'trending' && !seller_id && rows.length > NEW_SELLER_SLOTS) {
|
|
249
|
+
const headSliceForCheck = rows.slice(0, lim);
|
|
250
|
+
const headSellerSet = new Set(headSliceForCheck.map(r => String(r.seller_id)));
|
|
251
|
+
const newSellerCandidates = [];
|
|
252
|
+
for (const r of rows) {
|
|
253
|
+
const sid = String(r.seller_id);
|
|
254
|
+
if (newSellerCandidates.some(x => String(x.seller_id) === sid))
|
|
255
|
+
continue;
|
|
256
|
+
if (headSellerSet.has(sid))
|
|
257
|
+
continue;
|
|
258
|
+
const sc = r.seller_created_at;
|
|
259
|
+
if (!sc)
|
|
260
|
+
continue;
|
|
261
|
+
const ageMs = Date.now() - new Date(sc.replace(' ', 'T') + 'Z').getTime();
|
|
262
|
+
if (ageMs <= NEW_SELLER_AGE_MS) {
|
|
263
|
+
newSellerCandidates.push(r);
|
|
264
|
+
if (newSellerCandidates.length >= NEW_SELLER_SLOTS)
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (newSellerCandidates.length) {
|
|
269
|
+
const newSet = new Set(newSellerCandidates.map(r => String(r.id)));
|
|
270
|
+
const others = rows.filter(r => !newSet.has(String(r.id)));
|
|
271
|
+
const keepHead = others.slice(0, lim - newSellerCandidates.length);
|
|
272
|
+
rows = [...keepHead, ...newSellerCandidates];
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
rows = rows.slice(0, lim);
|
|
276
|
+
// 下一页 cursor — 基于 rows 中 raw score 最低位(防 jitter 翻页丢候选)
|
|
277
|
+
let nextCursor = null;
|
|
278
|
+
const hasMore = ((sort === 'trending' || sort === 'newest')
|
|
279
|
+
&& rows.length > 0
|
|
280
|
+
&& (candidates.length >= buffer || rows.length === lim));
|
|
281
|
+
if (hasMore) {
|
|
282
|
+
let anchor = rows[0];
|
|
283
|
+
for (const r of rows) {
|
|
284
|
+
const aScore = Number(anchor.trending_score) || 0;
|
|
285
|
+
const rScore = Number(r.trending_score) || 0;
|
|
286
|
+
if (sort === 'trending') {
|
|
287
|
+
if (rScore < aScore || (rScore === aScore && String(r.id) < String(anchor.id)))
|
|
288
|
+
anchor = r;
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
if (String(r.created_at) < String(anchor.created_at) || (String(r.created_at) === String(anchor.created_at) && String(r.id) < String(anchor.id)))
|
|
292
|
+
anchor = r;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (sort === 'trending') {
|
|
296
|
+
nextCursor = encodeProductCursor(Number(anchor.trending_score) || 0, String(anchor.id));
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
const jd = db.prepare(`SELECT julianday(?) as j`).get(anchor.created_at).j;
|
|
300
|
+
nextCursor = encodeProductCursor(jd, String(anchor.id));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (nextCursor)
|
|
304
|
+
res.setHeader('X-Next-Cursor', nextCursor);
|
|
305
|
+
res.setHeader('X-Sort', sort);
|
|
306
|
+
res.setHeader('X-Mode', mode);
|
|
307
|
+
if (matchMode !== 'none')
|
|
308
|
+
res.setHeader('X-Match-Mode', matchMode);
|
|
309
|
+
res.setHeader('Access-Control-Expose-Headers', 'X-Next-Cursor, X-Sort, X-Mode, X-Match-Mode');
|
|
310
|
+
if (mode === 'pwa') {
|
|
311
|
+
// 兼容旧前端:返回数组;cursor 走 X-Next-Cursor header
|
|
312
|
+
// 库存稀缺感 — stock ≤3 且 last_sold_at 近 7d 才标 low_stock
|
|
313
|
+
return void res.json(rows.map(r => {
|
|
314
|
+
const stockNum = Number(r.stock) || 0;
|
|
315
|
+
const lastSoldRecent = r.last_sold_at
|
|
316
|
+
&& (Date.now() - new Date(String(r.last_sold_at).replace(' ', 'T') + 'Z').getTime()) < 7 * 86400_000;
|
|
317
|
+
const lowStock = stockNum > 0 && stockNum <= 3 && lastSoldRecent;
|
|
318
|
+
return {
|
|
319
|
+
...formatProductForAgent(r),
|
|
320
|
+
sales_count: Number(r.sales_count) || 0,
|
|
321
|
+
product_type: r.product_type || 'retail',
|
|
322
|
+
low_stock: lowStock ? stockNum : 0,
|
|
323
|
+
};
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
if (mode === 'raw') {
|
|
327
|
+
// 原始数据 + HMAC 签名(trust ≥ 30 已通过)
|
|
328
|
+
const payload = {
|
|
329
|
+
mode, sort, limit: lim, cursor: nextCursor,
|
|
330
|
+
generated_at: new Date().toISOString(),
|
|
331
|
+
count: rows.length,
|
|
332
|
+
products: rows.map(r => ({
|
|
333
|
+
id: r.id,
|
|
334
|
+
title: r.title,
|
|
335
|
+
seller_id: r.seller_id,
|
|
336
|
+
seller_name: r.seller_name,
|
|
337
|
+
price: r.price,
|
|
338
|
+
stock: r.stock,
|
|
339
|
+
category: r.category,
|
|
340
|
+
category_id: r.category_id,
|
|
341
|
+
ship_regions: r.ship_regions,
|
|
342
|
+
commission_rate: r.commission_rate,
|
|
343
|
+
rep_points: Number(r.rep_points) || 0,
|
|
344
|
+
rep_level: r.rep_level || 'new',
|
|
345
|
+
completion_count: Number(r.completion_count) || 0,
|
|
346
|
+
dispute_loss_count: Number(r.dispute_loss_count) || 0,
|
|
347
|
+
unique_sharer_count: Number(r.unique_sharer_count) || 0,
|
|
348
|
+
last_sold_at: r.last_sold_at,
|
|
349
|
+
sales_count: Number(r.sales_count) || 0,
|
|
350
|
+
trending_score: Number(r.trending_score) || 0,
|
|
351
|
+
created_at: r.created_at,
|
|
352
|
+
})),
|
|
353
|
+
};
|
|
354
|
+
const signature = createHmac('sha256', MASTER_SEED).update(JSON.stringify(payload)).digest('hex');
|
|
355
|
+
res.setHeader('X-Signature', signature);
|
|
356
|
+
res.setHeader('X-Signature-Algo', 'HMAC-SHA256');
|
|
357
|
+
return void res.json(payload);
|
|
358
|
+
}
|
|
359
|
+
// agent 模式:富信息 + 元数据
|
|
360
|
+
res.json({
|
|
361
|
+
mode, sort, limit: lim, cursor: nextCursor,
|
|
362
|
+
count: rows.length,
|
|
363
|
+
products: rows.map(r => {
|
|
364
|
+
const formatted = formatProductForAgent(r);
|
|
365
|
+
const completion = Number(r.completion_count) || 0;
|
|
366
|
+
const dispute = Number(r.dispute_loss_count) || 0;
|
|
367
|
+
const sharer = Number(r.unique_sharer_count) || 0;
|
|
368
|
+
const rep = Number(r.rep_points) || 0;
|
|
369
|
+
// 与 SQL TRENDING_SCORE_EXPR 阶梯保持一致
|
|
370
|
+
const ageDaysSinceSold = r.last_sold_at
|
|
371
|
+
? (Date.now() - new Date(String(r.last_sold_at).replace(' ', 'T') + 'Z').getTime()) / 86400_000
|
|
372
|
+
: null;
|
|
373
|
+
let freshness = 0;
|
|
374
|
+
if (ageDaysSinceSold !== null) {
|
|
375
|
+
if (ageDaysSinceSold < 30)
|
|
376
|
+
freshness = 10;
|
|
377
|
+
else if (ageDaysSinceSold < 90)
|
|
378
|
+
freshness = 10 * (1 - (ageDaysSinceSold - 30) / 60);
|
|
379
|
+
else if (ageDaysSinceSold < 180)
|
|
380
|
+
freshness = -5;
|
|
381
|
+
else
|
|
382
|
+
freshness = -15;
|
|
383
|
+
}
|
|
384
|
+
const ageDaysSinceFirst = r.first_sold_at
|
|
385
|
+
? (Date.now() - new Date(String(r.first_sold_at).replace(' ', 'T') + 'Z').getTime()) / 86400_000
|
|
386
|
+
: null;
|
|
387
|
+
const firstSaleBoost = ageDaysSinceFirst !== null && ageDaysSinceFirst < 14 ? 5 : 0;
|
|
388
|
+
// 季节性:与 SQL CASE 同步
|
|
389
|
+
const seasonalCsv = r.seasonal_months;
|
|
390
|
+
let seasonalPenalty = 0;
|
|
391
|
+
if (seasonalCsv) {
|
|
392
|
+
const currentMonth = new Date().getUTCMonth() + 1;
|
|
393
|
+
const activeMonths = seasonalCsv.split(',').map(s => Number(s.trim())).filter(n => n >= 1 && n <= 12);
|
|
394
|
+
if (activeMonths.length && !activeMonths.includes(currentMonth))
|
|
395
|
+
seasonalPenalty = -10;
|
|
396
|
+
}
|
|
397
|
+
const score = Number(r.trending_score) || 0;
|
|
398
|
+
return {
|
|
399
|
+
...formatted,
|
|
400
|
+
sales_count: Number(r.sales_count) || 0,
|
|
401
|
+
metrics: {
|
|
402
|
+
completion_count: completion,
|
|
403
|
+
dispute_loss_count: dispute,
|
|
404
|
+
unique_sharer_count: sharer,
|
|
405
|
+
last_sold_at: r.last_sold_at || null,
|
|
406
|
+
first_sold_at: r.first_sold_at || null,
|
|
407
|
+
rep_points: rep,
|
|
408
|
+
rep_level: r.rep_level || 'new',
|
|
409
|
+
},
|
|
410
|
+
score,
|
|
411
|
+
score_breakdown: {
|
|
412
|
+
completion: Math.round(completion * 0.5 * 100) / 100,
|
|
413
|
+
rep: Math.round(rep * 0.1 * 100) / 100,
|
|
414
|
+
unique_sharer: Math.round(sharer * 2.0 * 100) / 100,
|
|
415
|
+
freshness: Math.round(freshness * 100) / 100,
|
|
416
|
+
first_sale_boost: firstSaleBoost,
|
|
417
|
+
seasonal_penalty: seasonalPenalty,
|
|
418
|
+
dispute_penalty: Math.round(-dispute * 5.0 * 100) / 100,
|
|
419
|
+
},
|
|
420
|
+
};
|
|
421
|
+
}),
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
export function registerProductsMetaRoutes(app, deps) {
|
|
2
|
+
const { db, auth, generateId, rateLimitOk, flagNewAccountShareable, refreshProductSharerCount } = deps;
|
|
3
|
+
const PH_RATE = 60; // 每 IP/分钟 60 次
|
|
4
|
+
const PH_MIN_SAMPLE = 5;
|
|
5
|
+
app.get('/api/products/:id/price-history', (req, res) => {
|
|
6
|
+
const ip = req.ip || 'unknown';
|
|
7
|
+
if (!rateLimitOk(`ph:${ip}`, PH_RATE, 60_000))
|
|
8
|
+
return void res.status(429).json({ error: 'rate-limited' });
|
|
9
|
+
const p = db.prepare("SELECT id, price as current_price, category, category_id FROM products WHERE id = ?").get(req.params.id);
|
|
10
|
+
if (!p)
|
|
11
|
+
return void res.status(404).json({ error: 'not_found' });
|
|
12
|
+
const lifetimeCount = db.prepare(`SELECT COUNT(1) as n FROM orders WHERE product_id = ? AND status = 'completed'`).get(req.params.id).n;
|
|
13
|
+
if (lifetimeCount < PH_MIN_SAMPLE) {
|
|
14
|
+
return void res.json({ product_id: req.params.id, current_price: p.current_price, insufficient_data: true, lifetime_sales: lifetimeCount, min_sample: PH_MIN_SAMPLE });
|
|
15
|
+
}
|
|
16
|
+
function aggregateWindow(days) {
|
|
17
|
+
const where = days != null
|
|
18
|
+
? `product_id = ? AND status = 'completed' AND created_at > datetime('now', '-' || ? || ' days')`
|
|
19
|
+
: `product_id = ? AND status = 'completed'`;
|
|
20
|
+
const args = days != null ? [req.params.id, days] : [req.params.id];
|
|
21
|
+
const row = db.prepare(`
|
|
22
|
+
SELECT COUNT(1) as sales, COALESCE(SUM(total_amount), 0) as volume,
|
|
23
|
+
COALESCE(AVG(unit_price), 0) as avg
|
|
24
|
+
FROM orders WHERE ${where}
|
|
25
|
+
`).get(...args);
|
|
26
|
+
if (row.sales === 0)
|
|
27
|
+
return { sales: 0, volume: 0, avg: 0, median: 0, p25: 0, p75: 0 };
|
|
28
|
+
// SQLite 无 percentile 函数;P1 fix #1: LIMIT 5000 防爆
|
|
29
|
+
const prices = db.prepare(`SELECT unit_price FROM orders WHERE ${where} ORDER BY unit_price ASC LIMIT 5000`).all(...args).map(r => Number(r.unit_price));
|
|
30
|
+
// P1 fix #2: 真百分位(linear interp)
|
|
31
|
+
const pct = (frac) => {
|
|
32
|
+
if (!prices.length)
|
|
33
|
+
return 0;
|
|
34
|
+
const idx = (prices.length - 1) * frac;
|
|
35
|
+
const lo = Math.floor(idx);
|
|
36
|
+
const hi = Math.ceil(idx);
|
|
37
|
+
if (lo === hi)
|
|
38
|
+
return prices[lo];
|
|
39
|
+
const w = idx - lo;
|
|
40
|
+
return Math.round((prices[lo] * (1 - w) + prices[hi] * w) * 100) / 100;
|
|
41
|
+
};
|
|
42
|
+
return {
|
|
43
|
+
sales: row.sales,
|
|
44
|
+
volume: Math.round(row.volume * 100) / 100,
|
|
45
|
+
avg: Math.round(row.avg * 100) / 100,
|
|
46
|
+
median: pct(0.5),
|
|
47
|
+
p25: pct(0.25),
|
|
48
|
+
p75: pct(0.75),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const d30 = aggregateWindow(30);
|
|
52
|
+
const d90 = aggregateWindow(90);
|
|
53
|
+
const lifetime = aggregateWindow(null);
|
|
54
|
+
// 价位分布(90 天内,最多 20 个 bucket)
|
|
55
|
+
const buckets = db.prepare(`
|
|
56
|
+
SELECT unit_price as price, COUNT(1) as count, COALESCE(SUM(quantity), COUNT(1)) as qty
|
|
57
|
+
FROM orders WHERE product_id = ? AND status = 'completed' AND created_at > datetime('now', '-90 days')
|
|
58
|
+
GROUP BY unit_price ORDER BY unit_price ASC LIMIT 20
|
|
59
|
+
`).all(req.params.id);
|
|
60
|
+
const totalBucketSales = buckets.reduce((s, b) => s + b.count, 0) || 1;
|
|
61
|
+
const priceBuckets = buckets.map(b => ({
|
|
62
|
+
price: Number(b.price),
|
|
63
|
+
count: b.count,
|
|
64
|
+
qty: b.qty,
|
|
65
|
+
pct: Math.round((b.count / totalBucketSales) * 10000) / 100,
|
|
66
|
+
}));
|
|
67
|
+
// 30 日日均价 sparkline — P1 fix #4: 单日 sales=1 不返回 avg(防反查买家身份)
|
|
68
|
+
const daily = db.prepare(`
|
|
69
|
+
SELECT substr(created_at, 1, 10) as date, COUNT(1) as sales, AVG(unit_price) as avg
|
|
70
|
+
FROM orders WHERE product_id = ? AND status = 'completed' AND created_at > datetime('now', '-30 days')
|
|
71
|
+
GROUP BY date ORDER BY date ASC
|
|
72
|
+
`).all(req.params.id);
|
|
73
|
+
const dailyAvg = daily.map(d => ({
|
|
74
|
+
date: d.date,
|
|
75
|
+
sales: d.sales,
|
|
76
|
+
avg: d.sales >= 2 ? Math.round(Number(d.avg) * 100) / 100 : null,
|
|
77
|
+
}));
|
|
78
|
+
// 同类目近 30 天均价对照(cat_default 不算 meaningful)
|
|
79
|
+
let categoryAvg30d = null;
|
|
80
|
+
if (p.category_id && p.category_id !== 'cat_default') {
|
|
81
|
+
const row = db.prepare(`
|
|
82
|
+
SELECT AVG(o.unit_price) as avg FROM orders o JOIN products p ON p.id = o.product_id
|
|
83
|
+
WHERE p.category_id = ? AND o.status = 'completed' AND o.created_at > datetime('now', '-30 days')
|
|
84
|
+
`).get(p.category_id);
|
|
85
|
+
categoryAvg30d = row.avg != null ? Math.round(Number(row.avg) * 100) / 100 : null;
|
|
86
|
+
}
|
|
87
|
+
// 异常预警
|
|
88
|
+
const flags = [];
|
|
89
|
+
if (d30.median > 0 && p.current_price < d30.median * 0.7)
|
|
90
|
+
flags.push('current_below_70pct_median');
|
|
91
|
+
if (categoryAvg30d != null && p.current_price < categoryAvg30d * 0.5)
|
|
92
|
+
flags.push('far_below_category_avg');
|
|
93
|
+
if (categoryAvg30d != null && p.current_price > categoryAvg30d * 3)
|
|
94
|
+
flags.push('far_above_category_avg');
|
|
95
|
+
res.json({
|
|
96
|
+
product_id: req.params.id,
|
|
97
|
+
current_price: p.current_price,
|
|
98
|
+
insufficient_data: false,
|
|
99
|
+
windows: { d30, d90, lifetime },
|
|
100
|
+
price_buckets: priceBuckets,
|
|
101
|
+
daily_avg: dailyAvg,
|
|
102
|
+
category_avg_30d: categoryAvg30d,
|
|
103
|
+
anomaly_flags: flags,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
// 公开预览:未登录可调,返回最小公开信息(分享 banner 用)
|
|
107
|
+
app.get('/api/products/:id/preview', (req, res) => {
|
|
108
|
+
const row = db.prepare(`
|
|
109
|
+
SELECT p.id, p.title, p.price, p.category, u.name as seller_name
|
|
110
|
+
FROM products p
|
|
111
|
+
JOIN users u ON p.seller_id = u.id
|
|
112
|
+
WHERE p.id = ? AND p.status = 'active'
|
|
113
|
+
`).get(req.params.id);
|
|
114
|
+
if (!row)
|
|
115
|
+
return void res.status(404).json({ error: 'not_found' });
|
|
116
|
+
res.json(row);
|
|
117
|
+
});
|
|
118
|
+
// 分享许可:是否对该商品有 completed 订单
|
|
119
|
+
app.get('/api/products/:id/can-share', (req, res) => {
|
|
120
|
+
const user = auth(req, res);
|
|
121
|
+
if (!user)
|
|
122
|
+
return;
|
|
123
|
+
const completed = db.prepare("SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND product_id = ? AND status = 'completed'").get(user.id, req.params.id).n;
|
|
124
|
+
res.json({
|
|
125
|
+
can_share: completed > 0,
|
|
126
|
+
completed_orders: completed,
|
|
127
|
+
reason: completed > 0 ? 'verified_buyer_of_product' : 'need_completed_order_of_this_product',
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
// 获取或创建商品 shareable(被 sharePromoLink 用,走 /s/<id> 短链)
|
|
131
|
+
app.post('/api/products/:id/get-or-create-share', (req, res) => {
|
|
132
|
+
const user = auth(req, res);
|
|
133
|
+
if (!user)
|
|
134
|
+
return;
|
|
135
|
+
const productId = req.params.id;
|
|
136
|
+
const completed = db.prepare("SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND product_id = ? AND status = 'completed'").get(user.id, productId).n;
|
|
137
|
+
if (completed === 0)
|
|
138
|
+
return void res.json({ error: '需先完成该商品的购买才能分享', completed_orders: 0 });
|
|
139
|
+
// 优先复用现有 active shareable
|
|
140
|
+
const existing = db.prepare(`SELECT id, owner_code FROM shareables WHERE owner_id = ? AND related_product_id = ? AND status = 'active' LIMIT 1`).get(user.id, productId);
|
|
141
|
+
if (existing) {
|
|
142
|
+
return void res.json({ ok: true, shareable_id: existing.id, owner_code: existing.owner_code, short_url: `/s/${existing.id}`, reused: true });
|
|
143
|
+
}
|
|
144
|
+
// 创建新 shareable(纯商品分享:无外链,无 native,仅绑 product_id)
|
|
145
|
+
const id = generateId('shr');
|
|
146
|
+
const ownerCode = db.prepare("SELECT permanent_code FROM users WHERE id = ?").get(user.id)?.permanent_code || null;
|
|
147
|
+
const product = db.prepare("SELECT title FROM products WHERE id = ?").get(productId);
|
|
148
|
+
db.prepare(`INSERT INTO shareables (id, owner_id, type, title, related_product_id, owner_code)
|
|
149
|
+
VALUES (?,?,?,?,?,?)`)
|
|
150
|
+
.run(id, user.id, 'product_promo', product?.title || null, productId, ownerCode);
|
|
151
|
+
flagNewAccountShareable(id, user.id);
|
|
152
|
+
refreshProductSharerCount(productId);
|
|
153
|
+
res.json({ ok: true, shareable_id: id, owner_code: ownerCode, short_url: `/s/${id}`, reused: false });
|
|
154
|
+
});
|
|
155
|
+
}
|