@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
|
+
export function registerPromoterRoutes(app, deps) {
|
|
2
|
+
const { db, auth, isAllowedSponsor } = deps;
|
|
3
|
+
app.get('/api/promoter/dashboard', (req, res) => {
|
|
4
|
+
const user = auth(req, res);
|
|
5
|
+
if (!user)
|
|
6
|
+
return;
|
|
7
|
+
const userId = user.id;
|
|
8
|
+
const l1 = db.prepare("SELECT COUNT(*) as n FROM users WHERE sponsor_id = ?").get(userId).n;
|
|
9
|
+
const l2 = db.prepare(`
|
|
10
|
+
SELECT COUNT(*) as n FROM users
|
|
11
|
+
WHERE sponsor_id IN (SELECT id FROM users WHERE sponsor_id = ?)
|
|
12
|
+
`).get(userId).n;
|
|
13
|
+
const l3 = db.prepare(`
|
|
14
|
+
SELECT COUNT(*) as n FROM users
|
|
15
|
+
WHERE sponsor_id IN (
|
|
16
|
+
SELECT id FROM users WHERE sponsor_id IN (SELECT id FROM users WHERE sponsor_id = ?)
|
|
17
|
+
)
|
|
18
|
+
`).get(userId).n;
|
|
19
|
+
const earned = db.prepare(`
|
|
20
|
+
SELECT level, COUNT(*) as orders, COALESCE(SUM(amount),0) as total
|
|
21
|
+
FROM commission_records WHERE beneficiary_id = ?
|
|
22
|
+
GROUP BY level
|
|
23
|
+
`).all(userId);
|
|
24
|
+
const byLevel = { 1: { orders: 0, total: 0 }, 2: { orders: 0, total: 0 }, 3: { orders: 0, total: 0 } };
|
|
25
|
+
for (const r of earned)
|
|
26
|
+
byLevel[r.level] = { orders: r.orders, total: r.total };
|
|
27
|
+
const grand = byLevel[1].total + byLevel[2].total + byLevel[3].total;
|
|
28
|
+
const recent = db.prepare(`
|
|
29
|
+
SELECT cr.id, cr.order_id, cr.level, cr.amount, cr.rate, cr.created_at,
|
|
30
|
+
u.name as source_buyer_name
|
|
31
|
+
FROM commission_records cr
|
|
32
|
+
LEFT JOIN users u ON u.id = cr.source_buyer_id
|
|
33
|
+
WHERE cr.beneficiary_id = ?
|
|
34
|
+
ORDER BY cr.created_at DESC LIMIT 20
|
|
35
|
+
`).all(userId);
|
|
36
|
+
const me = db.prepare("SELECT sponsor_id, sponsor_path, region FROM users WHERE id = ?").get(userId);
|
|
37
|
+
const mySponsor = me?.sponsor_id ? db.prepare("SELECT name FROM users WHERE id = ?").get(me.sponsor_id) : null;
|
|
38
|
+
const myUser = db.prepare("SELECT total_left_pv, total_right_pv, left_child_id, right_child_id, placement_id, placement_side FROM users WHERE id = ?").get(userId);
|
|
39
|
+
const leftChildName = myUser?.left_child_id ? db.prepare("SELECT name FROM users WHERE id = ?").get(myUser.left_child_id)?.name : null;
|
|
40
|
+
const rightChildName = myUser?.right_child_id ? db.prepare("SELECT name FROM users WHERE id = ?").get(myUser.right_child_id)?.name : null;
|
|
41
|
+
const myPlacementName = myUser?.placement_id ? db.prepare("SELECT name FROM users WHERE id = ?").get(myUser.placement_id)?.name : null;
|
|
42
|
+
const scoreAgg = db.prepare(`
|
|
43
|
+
SELECT
|
|
44
|
+
COALESCE(SUM(CASE WHEN settled_at IS NULL THEN score ELSE 0 END),0) as pending_score,
|
|
45
|
+
COALESCE(SUM(CASE WHEN settled_at IS NOT NULL THEN waz_amount ELSE 0 END),0) as settled_waz,
|
|
46
|
+
COUNT(*) as total_hits
|
|
47
|
+
FROM binary_score_records WHERE user_id = ?
|
|
48
|
+
`).get(userId);
|
|
49
|
+
const recentBinary = db.prepare(`
|
|
50
|
+
SELECT id, tier, score, settled_at, waz_amount, created_at
|
|
51
|
+
FROM binary_score_records WHERE user_id = ?
|
|
52
|
+
ORDER BY created_at DESC LIMIT 10
|
|
53
|
+
`).all(userId);
|
|
54
|
+
const tiers = db.prepare("SELECT tier, pv_threshold, score_per_hit FROM binary_tier_config WHERE active=1 ORDER BY tier ASC").all();
|
|
55
|
+
const canL1Share = isAllowedSponsor(userId);
|
|
56
|
+
const completedOrders = db.prepare("SELECT COUNT(*) as n FROM orders WHERE buyer_id = ? AND status = 'completed'").get(userId).n;
|
|
57
|
+
const overrideRow = db.prepare("SELECT l1_share_override FROM users WHERE id = ?").get(userId);
|
|
58
|
+
const shareableProducts = db.prepare(`
|
|
59
|
+
SELECT p.id, p.title, p.price, p.category, p.commission_rate,
|
|
60
|
+
(SELECT COUNT(*) FROM orders o WHERE o.product_id = p.id AND o.status = 'completed') as total_sales,
|
|
61
|
+
COALESCE((SELECT SUM(cr.amount) FROM commission_records cr
|
|
62
|
+
JOIN orders o2 ON o2.id = cr.order_id
|
|
63
|
+
WHERE cr.beneficiary_id = ? AND o2.product_id = p.id), 0) as my_earned
|
|
64
|
+
FROM products p
|
|
65
|
+
WHERE p.id IN (SELECT DISTINCT product_id FROM orders WHERE buyer_id = ? AND status = 'completed')
|
|
66
|
+
AND p.commission_rate IS NOT NULL AND p.commission_rate > 0
|
|
67
|
+
AND p.status = 'active'
|
|
68
|
+
ORDER BY my_earned DESC, total_sales DESC LIMIT 20
|
|
69
|
+
`).all(userId, userId);
|
|
70
|
+
const earnedLast30 = db.prepare(`
|
|
71
|
+
SELECT COALESCE(SUM(amount),0) as total FROM commission_records
|
|
72
|
+
WHERE beneficiary_id = ? AND created_at >= datetime('now','-30 days')
|
|
73
|
+
`).get(userId).total;
|
|
74
|
+
const earnedPrev30 = db.prepare(`
|
|
75
|
+
SELECT COALESCE(SUM(amount),0) as total FROM commission_records
|
|
76
|
+
WHERE beneficiary_id = ? AND created_at >= datetime('now','-60 days')
|
|
77
|
+
AND created_at < datetime('now','-30 days')
|
|
78
|
+
`).get(userId).total;
|
|
79
|
+
const wazLast30 = db.prepare(`
|
|
80
|
+
SELECT COALESCE(SUM(waz_amount),0) as total FROM binary_score_records
|
|
81
|
+
WHERE user_id = ? AND settled_at >= datetime('now','-30 days')
|
|
82
|
+
`).get(userId).total;
|
|
83
|
+
const projection = {
|
|
84
|
+
last_30_commission: earnedLast30,
|
|
85
|
+
prev_30_commission: earnedPrev30,
|
|
86
|
+
last_30_atomic_waz: wazLast30,
|
|
87
|
+
growth_rate: earnedPrev30 > 0 ? earnedLast30 / earnedPrev30 - 1 : null,
|
|
88
|
+
next_30_estimate: earnedLast30 + wazLast30,
|
|
89
|
+
};
|
|
90
|
+
const insights = [];
|
|
91
|
+
const leftPv = Number(myUser?.total_left_pv ?? 0);
|
|
92
|
+
const rightPv = Number(myUser?.total_right_pv ?? 0);
|
|
93
|
+
if (leftPv > 0 || rightPv > 0) {
|
|
94
|
+
const max = Math.max(leftPv, rightPv), min = Math.min(leftPv, rightPv);
|
|
95
|
+
const ratio = max > 0 ? min / max : 0;
|
|
96
|
+
const weak = leftPv < rightPv ? '左区' : '右区';
|
|
97
|
+
if (ratio < 0.5)
|
|
98
|
+
insights.push({ type: 'weak_leg', level: 'warn', text: `${weak} PV 仅为强腿的 ${(ratio * 100).toFixed(0)}% — 建议主推${weak},对碰 = min(L,R) × tier_score` });
|
|
99
|
+
else
|
|
100
|
+
insights.push({ type: 'balanced', level: 'success', text: `双腿均衡度 ${(ratio * 100).toFixed(0)}% — 对碰节奏健康` });
|
|
101
|
+
}
|
|
102
|
+
const lastInvite = db.prepare(`SELECT MAX(created_at) as t FROM users WHERE sponsor_id = ?`).get(userId);
|
|
103
|
+
if (lastInvite.t) {
|
|
104
|
+
const days = Math.floor((Date.now() - new Date(lastInvite.t).getTime()) / 86400_000);
|
|
105
|
+
if (days > 14)
|
|
106
|
+
insights.push({ type: 'dormancy', level: 'warn', text: `${days} 天没有新直推 — 链接还在裤兜里?` });
|
|
107
|
+
else if (days < 3)
|
|
108
|
+
insights.push({ type: 'hot', level: 'success', text: `${days} 天前刚有新直推 — 趁热打铁` });
|
|
109
|
+
}
|
|
110
|
+
else if (l1 === 0) {
|
|
111
|
+
insights.push({ type: 'no_team', level: 'info', text: `还没有直推 — 先分享你买过且好评的商品给好友` });
|
|
112
|
+
}
|
|
113
|
+
const pair = Math.min(leftPv, rightPv);
|
|
114
|
+
const nextTier = tiers.find(t => t.pv_threshold > pair);
|
|
115
|
+
if (nextTier && pair > 0 && pair / nextTier.pv_threshold > 0.7) {
|
|
116
|
+
insights.push({ type: 'near_tier', level: 'success', text: `距离 tier ${nextTier.tier} 仅差 ${(nextTier.pv_threshold - pair).toLocaleString()} PV (+${nextTier.score_per_hit} Score / 次)` });
|
|
117
|
+
}
|
|
118
|
+
if (!canL1Share && completedOrders === 0) {
|
|
119
|
+
insights.push({ type: 'unlock', level: 'info', text: `完成首笔购买可解锁分享奖励 — 当前仅可分享 PV 加人扩双轨树` });
|
|
120
|
+
}
|
|
121
|
+
if (shareableProducts.length > 0 && grand === 0) {
|
|
122
|
+
insights.push({ type: 'first_share', level: 'info', text: `你有 ${shareableProducts.length} 个可分享商品但暂无成交 — 试着把链接发给身边的人` });
|
|
123
|
+
}
|
|
124
|
+
const treeNode = (uid) => {
|
|
125
|
+
if (!uid)
|
|
126
|
+
return null;
|
|
127
|
+
const u = db.prepare("SELECT id, name, total_left_pv, total_right_pv, left_child_id, right_child_id FROM users WHERE id = ?").get(uid);
|
|
128
|
+
if (!u)
|
|
129
|
+
return null;
|
|
130
|
+
return {
|
|
131
|
+
id: u.id, name: u.name,
|
|
132
|
+
lpv: Number(u.total_left_pv ?? 0),
|
|
133
|
+
rpv: Number(u.total_right_pv ?? 0),
|
|
134
|
+
left_id: u.left_child_id ?? null,
|
|
135
|
+
right_id: u.right_child_id ?? null,
|
|
136
|
+
};
|
|
137
|
+
};
|
|
138
|
+
const me_node = treeNode(userId);
|
|
139
|
+
const left_node = treeNode(myUser?.left_child_id);
|
|
140
|
+
const right_node = treeNode(myUser?.right_child_id);
|
|
141
|
+
const binaryTree = {
|
|
142
|
+
me: me_node,
|
|
143
|
+
left: left_node,
|
|
144
|
+
right: right_node,
|
|
145
|
+
ll: treeNode(left_node?.left_id),
|
|
146
|
+
lr: treeNode(left_node?.right_id),
|
|
147
|
+
rl: treeNode(right_node?.left_id),
|
|
148
|
+
rr: treeNode(right_node?.right_id),
|
|
149
|
+
};
|
|
150
|
+
const meCard = db.prepare("SELECT permanent_code, handle FROM users WHERE id = ?").get(userId);
|
|
151
|
+
const codeForLink = meCard?.permanent_code || userId;
|
|
152
|
+
res.json({
|
|
153
|
+
user_id: userId,
|
|
154
|
+
permanent_code: meCard?.permanent_code || null,
|
|
155
|
+
handle: meCard?.handle || null,
|
|
156
|
+
referral_link: `${req.protocol}://${req.get('host')}/i/${codeForLink}`,
|
|
157
|
+
region: me?.region || 'global',
|
|
158
|
+
my_sponsor: mySponsor ? { id: me.sponsor_id, name: mySponsor.name } : null,
|
|
159
|
+
permissions: {
|
|
160
|
+
can_l1_share: canL1Share,
|
|
161
|
+
completed_orders: completedOrders,
|
|
162
|
+
l1_share_override: overrideRow?.l1_share_override ?? 0,
|
|
163
|
+
reason: canL1Share
|
|
164
|
+
? (overrideRow?.l1_share_override === 1 ? 'admin_grant' : 'verified_buyer')
|
|
165
|
+
: 'need_completed_order',
|
|
166
|
+
},
|
|
167
|
+
team: { l1, l2, l3, total: l1 + l2 + l3 },
|
|
168
|
+
earnings: {
|
|
169
|
+
grand_total: grand,
|
|
170
|
+
l1: byLevel[1],
|
|
171
|
+
l2: byLevel[2],
|
|
172
|
+
l3: byLevel[3],
|
|
173
|
+
},
|
|
174
|
+
recent,
|
|
175
|
+
shareable_products: shareableProducts,
|
|
176
|
+
projection,
|
|
177
|
+
insights,
|
|
178
|
+
atomic: {
|
|
179
|
+
left_invite_url: `${req.protocol}://${req.get('host')}/i/${codeForLink}-L`,
|
|
180
|
+
right_invite_url: `${req.protocol}://${req.get('host')}/i/${codeForLink}-R`,
|
|
181
|
+
total_left_pv: Number(myUser?.total_left_pv ?? 0),
|
|
182
|
+
total_right_pv: Number(myUser?.total_right_pv ?? 0),
|
|
183
|
+
left_child: myUser?.left_child_id ? { id: myUser.left_child_id, name: leftChildName } : null,
|
|
184
|
+
right_child: myUser?.right_child_id ? { id: myUser.right_child_id, name: rightChildName } : null,
|
|
185
|
+
my_placement: myUser?.placement_id ? { id: myUser.placement_id, name: myPlacementName, side: myUser.placement_side } : null,
|
|
186
|
+
score: scoreAgg,
|
|
187
|
+
recent_binary: recentBinary,
|
|
188
|
+
tier_config: tiers,
|
|
189
|
+
binary_tree: binaryTree,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
// 直推 L1 列表
|
|
194
|
+
app.get('/api/promoter/team', (req, res) => {
|
|
195
|
+
const user = auth(req, res);
|
|
196
|
+
if (!user)
|
|
197
|
+
return;
|
|
198
|
+
const userId = user.id;
|
|
199
|
+
const rows = db.prepare(`
|
|
200
|
+
SELECT u.id, u.name, u.created_at, u.region,
|
|
201
|
+
(SELECT COUNT(*) FROM users WHERE sponsor_id = u.id) as their_l1,
|
|
202
|
+
COALESCE((SELECT SUM(amount) FROM commission_records WHERE beneficiary_id = ? AND source_buyer_id = u.id), 0) as my_earned_from_them
|
|
203
|
+
FROM users u WHERE u.sponsor_id = ?
|
|
204
|
+
ORDER BY u.created_at DESC LIMIT 100
|
|
205
|
+
`).all(userId, userId);
|
|
206
|
+
res.json({ team: rows });
|
|
207
|
+
});
|
|
208
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
export function registerPublicUtilsRoutes(app, deps) {
|
|
2
|
+
const { db, MASTER_SEED, NODE_ENV, SERVICE_START_MS, rateLimitOk, generateManifest, getUser, logError, issuerAddress } = deps;
|
|
3
|
+
app.get('/api/health', (_req, res) => {
|
|
4
|
+
const t0 = Date.now();
|
|
5
|
+
let dbOk = false;
|
|
6
|
+
let dbLatency = 0;
|
|
7
|
+
try {
|
|
8
|
+
const t1 = Date.now();
|
|
9
|
+
const r = db.prepare('SELECT 1 as ok').get();
|
|
10
|
+
dbLatency = Date.now() - t1;
|
|
11
|
+
dbOk = r?.ok === 1;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
dbOk = false;
|
|
15
|
+
}
|
|
16
|
+
const uptime = Math.floor((Date.now() - SERVICE_START_MS) / 1000);
|
|
17
|
+
const healthy = dbOk;
|
|
18
|
+
res.status(healthy ? 200 : 503).json({
|
|
19
|
+
status: healthy ? 'ok' : 'degraded',
|
|
20
|
+
uptime_sec: uptime,
|
|
21
|
+
db: { ok: dbOk, latency_ms: dbLatency },
|
|
22
|
+
seed_strength: MASTER_SEED === 'webaz-dev-seed-changeme' ? 'default' : MASTER_SEED.length >= 32 ? 'strong' : 'weak',
|
|
23
|
+
env: NODE_ENV,
|
|
24
|
+
check_ms: Date.now() - t0,
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
app.post('/api/mcp-telemetry', (req, res) => {
|
|
28
|
+
const ip = req.ip || 'unknown';
|
|
29
|
+
if (!rateLimitOk(ip))
|
|
30
|
+
return void res.status(429).json({ error: 'rate-limited' });
|
|
31
|
+
const { tool_name, outcome, latency_ms, user_id_hash, server_version } = req.body ?? {};
|
|
32
|
+
if (typeof tool_name !== 'string' || tool_name.length === 0 || tool_name.length > 64) {
|
|
33
|
+
return void res.status(400).json({ error: 'bad tool_name' });
|
|
34
|
+
}
|
|
35
|
+
if (outcome !== 'success' && outcome !== 'error') {
|
|
36
|
+
return void res.status(400).json({ error: 'bad outcome' });
|
|
37
|
+
}
|
|
38
|
+
const lat = Number(latency_ms);
|
|
39
|
+
if (!Number.isFinite(lat) || lat < 0 || lat > 60_000) {
|
|
40
|
+
return void res.status(400).json({ error: 'bad latency' });
|
|
41
|
+
}
|
|
42
|
+
const uih = typeof user_id_hash === 'string' && /^[0-9a-f]{1,32}$/.test(user_id_hash) ? user_id_hash : null;
|
|
43
|
+
const sv = typeof server_version === 'string' && server_version.length <= 32 ? server_version : null;
|
|
44
|
+
try {
|
|
45
|
+
db.prepare(`
|
|
46
|
+
INSERT INTO mcp_tool_calls (tool_name, user_id_hash, server_version, outcome, latency_ms)
|
|
47
|
+
VALUES (?, ?, ?, ?, ?)
|
|
48
|
+
`).run(tool_name, uih, sv, outcome, Math.round(lat));
|
|
49
|
+
}
|
|
50
|
+
catch { /* swallow — never fail telemetry */ }
|
|
51
|
+
res.json({ ok: true });
|
|
52
|
+
});
|
|
53
|
+
app.get('/api/system-flags', (_req, res) => {
|
|
54
|
+
const requireRef = db.prepare("SELECT value FROM system_state WHERE key='require_ref_to_register'").get()?.value === '1';
|
|
55
|
+
const inviteRotation = db.prepare("SELECT value FROM system_state WHERE key='invite_rotation_enabled'").get()?.value === '1';
|
|
56
|
+
res.json({ require_ref_to_register: requireRef, invite_rotation_enabled: inviteRotation });
|
|
57
|
+
});
|
|
58
|
+
// #1045 + #1048 整合 — 公开诚实化 manifest
|
|
59
|
+
// /.well-known/webaz-protocol.json — 标准 well-known URL,任何 HTTP 客户端可发现
|
|
60
|
+
// /api/protocol-status — JSON API 别名(同份内容)
|
|
61
|
+
// 内容:network_state(协议处于哪一阶段 + 诚实免责) + issuers(信任锚地址 + 轮换历史)
|
|
62
|
+
// 信任锚是 Phase 4 凭证(/api/me/agents/:prefix/passport)的验签依据,陌生第三方靠这个端点找到"webaz 官方地址"。
|
|
63
|
+
function buildProtocolManifest() {
|
|
64
|
+
const phase = db.prepare("SELECT value FROM system_state WHERE key='protocol_phase'").get()?.value || 'pre_launch';
|
|
65
|
+
// real_users = 已绑 Passkey 的账号数(我们的"真人"定义);pre-launch 应当 ≈0
|
|
66
|
+
const realUsers = db.prepare("SELECT COUNT(DISTINCT user_id) AS n FROM webauthn_credentials").get()?.n ?? 0;
|
|
67
|
+
const issuerActiveSince = db.prepare("SELECT value FROM system_state WHERE key='issuer_active_since'").get()?.value || '2026-05-30';
|
|
68
|
+
return {
|
|
69
|
+
name: 'WebAZ Protocol',
|
|
70
|
+
schema_version: 1,
|
|
71
|
+
network_state: {
|
|
72
|
+
phase,
|
|
73
|
+
real_users_on_canonical: realUsers,
|
|
74
|
+
canonical_endpoint: 'https://webaz.xyz',
|
|
75
|
+
economic_flow: 'simulated WAZ (test currency, 1 WAZ ≈ 1 USDC peg is a模拟基准, not a real exchange rate). No fiat/crypto settlement yet.',
|
|
76
|
+
disclaimer: {
|
|
77
|
+
zh: '本协议尚未公开上线,真实用户(已绑 Passkey 的账号)数实时反映在 real_users_on_canonical。无真实经济流转。请勿据此评估市场规模、做投资决策、或替终端用户承诺任何经济关系。',
|
|
78
|
+
en: 'Protocol is pre-launch; real_users_on_canonical reflects live count of Passkey-bound accounts. No real economic settlement yet. Do not use this data to evaluate market size, make investment decisions, or commit to third parties on a user\'s behalf.',
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
issuers: {
|
|
82
|
+
// 数组结构 — 将来轮换/泄露时往里追加新条目并把旧的设 revoked_at,验真方按签发时间判落在哪个有效区间
|
|
83
|
+
agent_passport: [
|
|
84
|
+
{
|
|
85
|
+
address: issuerAddress(),
|
|
86
|
+
scheme: 'eip191',
|
|
87
|
+
purpose: 'Phase 4 Agent Passport signing (custodian_fingerprint + risk_score + engagement_depth + behavior_profile)',
|
|
88
|
+
active_since: issuerActiveSince,
|
|
89
|
+
revoked_at: null,
|
|
90
|
+
verify: 'verifyMessage(address, passport.canonical, passport.signature) — any party can ecrecover without calling WebAZ',
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
// 路线图 — 回应"知道还有哪些没做"的诚实化第三层。哲学:公开当前到达点 + 已知未做项,不承诺时间表。
|
|
95
|
+
roadmap: {
|
|
96
|
+
philosophy: 'Disclose what is done + what is known-not-yet-done. We do not commit to deadlines (pre-launch). We DO commit to honest enumeration.',
|
|
97
|
+
completed: [
|
|
98
|
+
'Phase 1-4 Agent Passport: custodian + risk + behavior + signed portable export (cross-protocol ecrecover verifiable)',
|
|
99
|
+
'Phase 3a-3d access control: registration rate-limit + invite-required + declared-scope reads/writes + non-Passkey writers must declare',
|
|
100
|
+
'Iron-Rule: arbitrate / vote / agent_revoke / delete_passkey / large withdraw all require live WebAuthn ceremony',
|
|
101
|
+
'Integrity disclosure: MCP descriptions + /.well-known + pre-launch banner + protocol-status endpoint',
|
|
102
|
+
],
|
|
103
|
+
known_next: [
|
|
104
|
+
'Cross-user read daily cap (anti-aggregation; applies to Passkey humans too) — task #1043, awaits real usage to calibrate N',
|
|
105
|
+
'Registration captcha or email verification (anti-sybil last mile) — task #1049, pre-launch follow-up',
|
|
106
|
+
'Public economic model document — task #1050, pre-launch follow-up',
|
|
107
|
+
'Phase 5 ZK privacy L2/L3 — long-term research; triggered when real cross-protocol consumers appear',
|
|
108
|
+
],
|
|
109
|
+
deliberate_deferrals: [
|
|
110
|
+
'Model B (independent sub-agent keys with delegated scope) — destination locked; not building until real multi-agent demand surfaces',
|
|
111
|
+
'Parameterized fee rates (settleOrder currently hardcodes) — wire when fee governance launches',
|
|
112
|
+
'Binary/PV referral region MLM-term cleanup — region-gated to max=3 areas; deferred until structural decision on tree',
|
|
113
|
+
],
|
|
114
|
+
rationale_for_no_dates: 'pre-launch protocols that commit to dates either lie or rush. We commit only to a published list and to honesty about its state.',
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
app.get('/.well-known/webaz-protocol.json', (_req, res) => {
|
|
119
|
+
res.setHeader('Cache-Control', 'public, max-age=300'); // 5min 边缘缓存,降轮询
|
|
120
|
+
res.json(buildProtocolManifest());
|
|
121
|
+
});
|
|
122
|
+
app.get('/api/protocol-status', (_req, res) => {
|
|
123
|
+
res.setHeader('Cache-Control', 'public, max-age=300');
|
|
124
|
+
res.json(buildProtocolManifest());
|
|
125
|
+
});
|
|
126
|
+
app.get('/api/editor-picks', (_req, res) => {
|
|
127
|
+
const products = db.prepare(`
|
|
128
|
+
SELECT ep.id, ep.target_id, ep.title, ep.note, ep.starts_at, ep.ends_at, ep.sort_order,
|
|
129
|
+
p.title as product_title, p.price, p.images, p.category,
|
|
130
|
+
u.handle as seller_handle
|
|
131
|
+
FROM editor_picks ep
|
|
132
|
+
JOIN products p ON p.id = ep.target_id AND p.status = 'active'
|
|
133
|
+
JOIN users u ON u.id = p.seller_id
|
|
134
|
+
WHERE ep.kind = 'product' AND ep.starts_at <= datetime('now') AND ep.ends_at > datetime('now')
|
|
135
|
+
ORDER BY ep.sort_order ASC, ep.created_at DESC LIMIT 20
|
|
136
|
+
`).all();
|
|
137
|
+
const sellers = db.prepare(`
|
|
138
|
+
SELECT ep.id, ep.target_id, ep.title, ep.note, ep.starts_at, ep.ends_at, ep.sort_order,
|
|
139
|
+
u.handle, u.name, u.shop_banner_url, u.bio
|
|
140
|
+
FROM editor_picks ep
|
|
141
|
+
JOIN users u ON u.id = ep.target_id AND u.role = 'seller'
|
|
142
|
+
WHERE ep.kind = 'seller' AND ep.starts_at <= datetime('now') AND ep.ends_at > datetime('now')
|
|
143
|
+
ORDER BY ep.sort_order ASC, ep.created_at DESC LIMIT 20
|
|
144
|
+
`).all();
|
|
145
|
+
res.json({ products, sellers });
|
|
146
|
+
});
|
|
147
|
+
app.get('/api/manifest', (_req, res) => {
|
|
148
|
+
res.json(generateManifest(db));
|
|
149
|
+
});
|
|
150
|
+
const _errorReportLimiter = new Map();
|
|
151
|
+
app.post('/api/error-report', (req, res) => {
|
|
152
|
+
const ip = req.ip || 'unknown';
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
const bucket = _errorReportLimiter.get(ip) || { count: 0, reset: now + 60_000 };
|
|
155
|
+
if (now > bucket.reset) {
|
|
156
|
+
bucket.count = 0;
|
|
157
|
+
bucket.reset = now + 60_000;
|
|
158
|
+
}
|
|
159
|
+
bucket.count++;
|
|
160
|
+
_errorReportLimiter.set(ip, bucket);
|
|
161
|
+
if (bucket.count > 30)
|
|
162
|
+
return void res.status(429).json({ error: 'rate_limited' });
|
|
163
|
+
const { message, stack, url } = req.body || {};
|
|
164
|
+
if (!message || typeof message !== 'string')
|
|
165
|
+
return void res.status(400).json({ error: 'message required' });
|
|
166
|
+
const user = getUser(req);
|
|
167
|
+
logError('client', message.slice(0, 1000), { stack: String(stack || '').slice(0, 4000), url: String(url || '').slice(0, 500), user_agent: req.headers['user-agent'] || '', user_id: user?.id });
|
|
168
|
+
res.json({ ok: true });
|
|
169
|
+
});
|
|
170
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** web-push 失败回调:删除已失效订阅。导出以备 P1-5 任务调用。 */
|
|
2
|
+
export function cleanupStaleSubscription(db, endpoint) {
|
|
3
|
+
if (!endpoint)
|
|
4
|
+
return;
|
|
5
|
+
db.prepare('DELETE FROM push_subscriptions WHERE endpoint = ?').run(endpoint);
|
|
6
|
+
}
|
|
7
|
+
export function registerPushRoutes(app, deps) {
|
|
8
|
+
const { db, generateId, auth, vapidPublicKey } = deps;
|
|
9
|
+
app.get('/api/push/vapid-public-key', (_req, res) => {
|
|
10
|
+
if (!vapidPublicKey)
|
|
11
|
+
return void res.status(503).json({ error: '推送未配置,请联系管理员设置 VAPID_PUBLIC_KEY' });
|
|
12
|
+
res.json({ key: vapidPublicKey });
|
|
13
|
+
});
|
|
14
|
+
app.post('/api/push/subscribe', (req, res) => {
|
|
15
|
+
const user = auth(req, res);
|
|
16
|
+
if (!user)
|
|
17
|
+
return;
|
|
18
|
+
const { endpoint, keys, user_agent } = req.body || {};
|
|
19
|
+
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
|
20
|
+
return void res.status(400).json({ error: '订阅参数不完整(需 endpoint + keys.p256dh + keys.auth)' });
|
|
21
|
+
}
|
|
22
|
+
const id = generateId('psub');
|
|
23
|
+
// 同 user + endpoint 视作重新订阅
|
|
24
|
+
const existing = db.prepare('SELECT id FROM push_subscriptions WHERE user_id = ? AND endpoint = ?').get(user.id, String(endpoint));
|
|
25
|
+
if (existing) {
|
|
26
|
+
db.prepare('UPDATE push_subscriptions SET p256dh = ?, auth = ?, user_agent = ?, enabled = 1 WHERE id = ?')
|
|
27
|
+
.run(String(keys.p256dh), String(keys.auth), user_agent ? String(user_agent).slice(0, 200) : null, existing.id);
|
|
28
|
+
return void res.json({ success: true, id: existing.id });
|
|
29
|
+
}
|
|
30
|
+
db.prepare(`INSERT INTO push_subscriptions (id, user_id, endpoint, p256dh, auth, user_agent) VALUES (?,?,?,?,?,?)`)
|
|
31
|
+
.run(id, user.id, String(endpoint), String(keys.p256dh), String(keys.auth), user_agent ? String(user_agent).slice(0, 200) : null);
|
|
32
|
+
res.json({ success: true, id });
|
|
33
|
+
});
|
|
34
|
+
app.delete('/api/push/subscribe', (req, res) => {
|
|
35
|
+
const user = auth(req, res);
|
|
36
|
+
if (!user)
|
|
37
|
+
return;
|
|
38
|
+
const { endpoint } = req.body || {};
|
|
39
|
+
if (endpoint) {
|
|
40
|
+
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ? AND endpoint = ?').run(user.id, String(endpoint));
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
db.prepare('DELETE FROM push_subscriptions WHERE user_id = ?').run(user.id);
|
|
44
|
+
}
|
|
45
|
+
res.json({ success: true });
|
|
46
|
+
});
|
|
47
|
+
app.get('/api/push/status', (req, res) => {
|
|
48
|
+
const user = auth(req, res);
|
|
49
|
+
if (!user)
|
|
50
|
+
return;
|
|
51
|
+
const cnt = db.prepare('SELECT COUNT(*) as n FROM push_subscriptions WHERE user_id = ? AND enabled = 1').get(user.id).n;
|
|
52
|
+
res.json({ subscribed: cnt > 0, count: cnt, vapid_configured: !!vapidPublicKey });
|
|
53
|
+
});
|
|
54
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { recordRatingReputation } from '../../layer4-economics/L4-3-reputation/reputation-engine.js';
|
|
2
|
+
const RATING_BLIND_DAYS = 14;
|
|
3
|
+
function parseDim(v) {
|
|
4
|
+
const n = Number(v);
|
|
5
|
+
return Number.isInteger(n) && n >= 1 && n <= 5 ? n : null;
|
|
6
|
+
}
|
|
7
|
+
export function registerRatingsRoutes(app, deps) {
|
|
8
|
+
const { db, generateId, auth, isTrustedRole, errorRes, broadcastSystemEvent } = deps;
|
|
9
|
+
// buyer → seller 评价(一单一评,仅 completed 订单可评)
|
|
10
|
+
app.post('/api/orders/:order_id/rating', (req, res) => {
|
|
11
|
+
const user = auth(req, res);
|
|
12
|
+
if (!user)
|
|
13
|
+
return;
|
|
14
|
+
if (isTrustedRole(user))
|
|
15
|
+
return void errorRes(res, 403, 'TRUSTED_ROLE_NO_TRADE', '受信角色无购物功能');
|
|
16
|
+
const order = db.prepare('SELECT id, buyer_id, seller_id, product_id, status FROM orders WHERE id = ?').get(req.params.order_id);
|
|
17
|
+
if (!order)
|
|
18
|
+
return void res.status(404).json({ error: '订单不存在' });
|
|
19
|
+
if (order.buyer_id !== user.id)
|
|
20
|
+
return void res.status(403).json({ error: '仅买家可评价' });
|
|
21
|
+
if (order.status !== 'completed')
|
|
22
|
+
return void res.status(400).json({ error: '订单完成后才能评价' });
|
|
23
|
+
const existing = db.prepare('SELECT order_id FROM order_ratings WHERE order_id = ?').get(order.id);
|
|
24
|
+
if (existing)
|
|
25
|
+
return void res.status(400).json({ error: '已评价过,每单仅可评一次' });
|
|
26
|
+
const stars = Number(req.body?.stars);
|
|
27
|
+
if (!Number.isInteger(stars) || stars < 1 || stars > 5)
|
|
28
|
+
return void res.status(400).json({ error: '评分需在 1-5 之间' });
|
|
29
|
+
const comment = req.body?.comment ? String(req.body.comment).slice(0, 1000) : null;
|
|
30
|
+
const dimQuality = parseDim(req.body?.dim_quality);
|
|
31
|
+
const dimSpeed = parseDim(req.body?.dim_speed);
|
|
32
|
+
const dimService = parseDim(req.body?.dim_service);
|
|
33
|
+
const hiddenUntil = new Date(Date.now() + RATING_BLIND_DAYS * 24 * 3600 * 1000).toISOString();
|
|
34
|
+
db.transaction(() => {
|
|
35
|
+
db.prepare(`INSERT INTO order_ratings (order_id, buyer_id, seller_id, product_id, stars, comment, dim_quality, dim_speed, dim_service, hidden_until)
|
|
36
|
+
VALUES (?,?,?,?,?,?,?,?,?,?)`)
|
|
37
|
+
.run(order.id, order.buyer_id, order.seller_id, order.product_id, stars, comment, dimQuality, dimSpeed, dimService, hiddenUntil);
|
|
38
|
+
try {
|
|
39
|
+
db.prepare(`INSERT INTO notifications (id, user_id, title, body, order_id) VALUES (?,?,?,?,?)`)
|
|
40
|
+
.run(generateId('ntf'), order.seller_id, `⭐ 收到 ${stars} 星评价`, comment ? String(comment).slice(0, 100) : '买家未留言', order.id);
|
|
41
|
+
}
|
|
42
|
+
catch { }
|
|
43
|
+
// L2-5 反哺声誉
|
|
44
|
+
try {
|
|
45
|
+
recordRatingReputation(db, { orderId: order.id, revieweeId: order.seller_id, revieweeRole: 'seller', stars });
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
console.warn('[rating→rep] seller delta failed:', e.message);
|
|
49
|
+
}
|
|
50
|
+
})();
|
|
51
|
+
try {
|
|
52
|
+
broadcastSystemEvent('rating', '⭐', `${stars} 星评价 (订单 ${order.id})`, order.id);
|
|
53
|
+
}
|
|
54
|
+
catch { }
|
|
55
|
+
res.json({ success: true });
|
|
56
|
+
});
|
|
57
|
+
// seller → buyer 反向评价
|
|
58
|
+
app.post('/api/orders/:order_id/buyer-rating', (req, res) => {
|
|
59
|
+
const user = auth(req, res);
|
|
60
|
+
if (!user)
|
|
61
|
+
return;
|
|
62
|
+
const order = db.prepare('SELECT id, buyer_id, seller_id, status FROM orders WHERE id = ?').get(req.params.order_id);
|
|
63
|
+
if (!order)
|
|
64
|
+
return void res.status(404).json({ error: '订单不存在' });
|
|
65
|
+
if (order.seller_id !== user.id)
|
|
66
|
+
return void res.status(403).json({ error: '仅卖家可评价买家' });
|
|
67
|
+
if (order.status !== 'completed')
|
|
68
|
+
return void res.status(400).json({ error: '订单完成后才能评价' });
|
|
69
|
+
const existing = db.prepare('SELECT order_id FROM buyer_ratings WHERE order_id = ?').get(order.id);
|
|
70
|
+
if (existing)
|
|
71
|
+
return void res.status(400).json({ error: '已评价过,每单仅可评一次' });
|
|
72
|
+
const stars = Number(req.body?.stars);
|
|
73
|
+
if (!Number.isInteger(stars) || stars < 1 || stars > 5)
|
|
74
|
+
return void res.status(400).json({ error: '评分需在 1-5 之间' });
|
|
75
|
+
const comment = req.body?.comment ? String(req.body.comment).slice(0, 1000) : null;
|
|
76
|
+
const dimPay = parseDim(req.body?.dim_payment_speed);
|
|
77
|
+
const dimCom = parseDim(req.body?.dim_communication);
|
|
78
|
+
const dimRsp = parseDim(req.body?.dim_responsiveness);
|
|
79
|
+
const hiddenUntil = new Date(Date.now() + RATING_BLIND_DAYS * 24 * 3600 * 1000).toISOString();
|
|
80
|
+
db.transaction(() => {
|
|
81
|
+
db.prepare(`INSERT INTO buyer_ratings (order_id, seller_id, buyer_id, stars, comment, dim_payment_speed, dim_communication, dim_responsiveness, hidden_until)
|
|
82
|
+
VALUES (?,?,?,?,?,?,?,?,?)`)
|
|
83
|
+
.run(order.id, order.seller_id, order.buyer_id, stars, comment, dimPay, dimCom, dimRsp, hiddenUntil);
|
|
84
|
+
try {
|
|
85
|
+
db.prepare(`INSERT INTO notifications (id, user_id, title, body, order_id) VALUES (?,?,?,?,?)`)
|
|
86
|
+
.run(generateId('ntf'), order.buyer_id, `⭐ 卖家给你 ${stars} 星评价`, comment ? String(comment).slice(0, 100) : '卖家未留言', order.id);
|
|
87
|
+
}
|
|
88
|
+
catch { }
|
|
89
|
+
try {
|
|
90
|
+
recordRatingReputation(db, { orderId: order.id, revieweeId: order.buyer_id, revieweeRole: 'buyer', stars });
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
console.warn('[buyer-rating→rep] delta failed:', e.message);
|
|
94
|
+
}
|
|
95
|
+
})();
|
|
96
|
+
res.json({ success: true });
|
|
97
|
+
});
|
|
98
|
+
// 查 seller → buyer 评价(双盲遮蔽:buyer 看不到,除非自己也评过 OR 窗口到期)
|
|
99
|
+
app.get('/api/orders/:order_id/buyer-rating', (req, res) => {
|
|
100
|
+
const user = auth(req, res);
|
|
101
|
+
if (!user)
|
|
102
|
+
return;
|
|
103
|
+
const order = db.prepare('SELECT buyer_id, seller_id FROM orders WHERE id = ?').get(req.params.order_id);
|
|
104
|
+
if (!order)
|
|
105
|
+
return void res.status(404).json({ error: '订单不存在' });
|
|
106
|
+
if (order.buyer_id !== user.id && order.seller_id !== user.id) {
|
|
107
|
+
return void res.status(403).json({ error: '无权查看' });
|
|
108
|
+
}
|
|
109
|
+
const br = db.prepare(`SELECT stars, comment, dim_payment_speed, dim_communication, dim_responsiveness, hidden_until, created_at FROM buyer_ratings WHERE order_id = ?`).get(req.params.order_id);
|
|
110
|
+
if (!br)
|
|
111
|
+
return void res.json({ item: null });
|
|
112
|
+
const isBuyerView = order.buyer_id === user.id;
|
|
113
|
+
const buyerAlsoRated = !!db.prepare(`SELECT order_id FROM order_ratings WHERE order_id = ?`).get(req.params.order_id);
|
|
114
|
+
const blindExpired = br.hidden_until && new Date(br.hidden_until) < new Date();
|
|
115
|
+
if (isBuyerView && !buyerAlsoRated && !blindExpired) {
|
|
116
|
+
return void res.json({ item: { masked: true, hidden_until: br.hidden_until, reason: 'blind_until_both_or_expire' } });
|
|
117
|
+
}
|
|
118
|
+
res.json({ item: br });
|
|
119
|
+
});
|
|
120
|
+
// 查 buyer → seller 评价(双盲遮蔽:seller 视角同样)
|
|
121
|
+
app.get('/api/orders/:order_id/rating', (req, res) => {
|
|
122
|
+
const user = auth(req, res);
|
|
123
|
+
if (!user)
|
|
124
|
+
return;
|
|
125
|
+
const order = db.prepare('SELECT buyer_id, seller_id FROM orders WHERE id = ?').get(req.params.order_id);
|
|
126
|
+
if (!order)
|
|
127
|
+
return void res.status(404).json({ error: '订单不存在' });
|
|
128
|
+
if (order.buyer_id !== user.id && order.seller_id !== user.id) {
|
|
129
|
+
return void res.status(403).json({ error: '无权查看' });
|
|
130
|
+
}
|
|
131
|
+
const r = db.prepare('SELECT stars, comment, reply, replied_at, buyer_followup, buyer_followup_at, dim_quality, dim_speed, dim_service, hidden_until, created_at FROM order_ratings WHERE order_id = ?').get(req.params.order_id);
|
|
132
|
+
if (!r)
|
|
133
|
+
return void res.json({ item: null });
|
|
134
|
+
const isSellerView = order.seller_id === user.id;
|
|
135
|
+
const sellerAlsoRated = !!db.prepare(`SELECT order_id FROM buyer_ratings WHERE order_id = ?`).get(req.params.order_id);
|
|
136
|
+
const blindExpired = r.hidden_until && new Date(r.hidden_until) < new Date();
|
|
137
|
+
if (isSellerView && !sellerAlsoRated && !blindExpired) {
|
|
138
|
+
return void res.json({ item: { masked: true, hidden_until: r.hidden_until, reason: 'blind_until_both_or_expire' } });
|
|
139
|
+
}
|
|
140
|
+
res.json({ item: r });
|
|
141
|
+
});
|
|
142
|
+
app.post('/api/orders/:order_id/rating/reply', (req, res) => {
|
|
143
|
+
const user = auth(req, res);
|
|
144
|
+
if (!user)
|
|
145
|
+
return;
|
|
146
|
+
const r = db.prepare('SELECT seller_id, reply FROM order_ratings WHERE order_id = ?').get(req.params.order_id);
|
|
147
|
+
if (!r)
|
|
148
|
+
return void res.status(404).json({ error: '该订单暂无评价' });
|
|
149
|
+
if (r.seller_id !== user.id)
|
|
150
|
+
return void res.status(403).json({ error: '仅卖家可回复' });
|
|
151
|
+
if (r.reply)
|
|
152
|
+
return void res.status(400).json({ error: '已回复过,每条评价仅可回复一次' });
|
|
153
|
+
const reply = req.body?.reply ? String(req.body.reply).slice(0, 500) : null;
|
|
154
|
+
if (!reply)
|
|
155
|
+
return void res.status(400).json({ error: '回复不能为空' });
|
|
156
|
+
db.prepare(`UPDATE order_ratings SET reply = ?, replied_at = datetime('now') WHERE order_id = ?`).run(reply, req.params.order_id);
|
|
157
|
+
res.json({ success: true });
|
|
158
|
+
});
|
|
159
|
+
// W3 买家追问 — 在卖家 reply 后可追问一次
|
|
160
|
+
app.post('/api/orders/:order_id/rating/followup', (req, res) => {
|
|
161
|
+
const user = auth(req, res);
|
|
162
|
+
if (!user)
|
|
163
|
+
return;
|
|
164
|
+
const r = db.prepare('SELECT buyer_id, reply, buyer_followup FROM order_ratings WHERE order_id = ?').get(req.params.order_id);
|
|
165
|
+
if (!r)
|
|
166
|
+
return void res.status(404).json({ error: '该订单暂无评价' });
|
|
167
|
+
if (r.buyer_id !== user.id)
|
|
168
|
+
return void res.status(403).json({ error: '仅买家可追问' });
|
|
169
|
+
if (!r.reply)
|
|
170
|
+
return void res.status(400).json({ error: '卖家尚未回复,无法追问' });
|
|
171
|
+
if (r.buyer_followup)
|
|
172
|
+
return void res.status(400).json({ error: '已追问过一次(每条评价最多一次追问)' });
|
|
173
|
+
const followup = req.body?.followup ? String(req.body.followup).trim().slice(0, 200) : '';
|
|
174
|
+
if (followup.length < 2)
|
|
175
|
+
return void res.status(400).json({ error: '追问内容至少 2 字' });
|
|
176
|
+
db.prepare(`UPDATE order_ratings SET buyer_followup = ?, buyer_followup_at = datetime('now') WHERE order_id = ?`)
|
|
177
|
+
.run(followup, req.params.order_id);
|
|
178
|
+
res.json({ success: true });
|
|
179
|
+
});
|
|
180
|
+
// 公开:商品评价 + 聚合(仅展示双盲已揭晓的)
|
|
181
|
+
app.get('/api/products/:product_id/ratings', (req, res) => {
|
|
182
|
+
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 20));
|
|
183
|
+
const blindOpen = `(EXISTS (SELECT 1 FROM buyer_ratings br WHERE br.order_id = r.order_id) OR r.hidden_until IS NULL OR datetime(r.hidden_until) <= datetime('now'))`;
|
|
184
|
+
const rows = db.prepare(`
|
|
185
|
+
SELECT r.stars, r.comment, r.reply, r.replied_at, r.buyer_followup, r.buyer_followup_at, r.created_at,
|
|
186
|
+
r.dim_quality, r.dim_speed, r.dim_service,
|
|
187
|
+
u.name as buyer_name, u.handle as buyer_handle
|
|
188
|
+
FROM order_ratings r
|
|
189
|
+
JOIN users u ON u.id = r.buyer_id
|
|
190
|
+
WHERE r.product_id = ? AND ${blindOpen}
|
|
191
|
+
ORDER BY r.created_at DESC LIMIT ?
|
|
192
|
+
`).all(req.params.product_id, limit);
|
|
193
|
+
const agg = db.prepare(`
|
|
194
|
+
SELECT COUNT(*) as cnt, COALESCE(AVG(stars), 0) as avg_stars,
|
|
195
|
+
SUM(CASE WHEN stars = 5 THEN 1 ELSE 0 END) as s5,
|
|
196
|
+
SUM(CASE WHEN stars = 4 THEN 1 ELSE 0 END) as s4,
|
|
197
|
+
SUM(CASE WHEN stars = 3 THEN 1 ELSE 0 END) as s3,
|
|
198
|
+
SUM(CASE WHEN stars = 2 THEN 1 ELSE 0 END) as s2,
|
|
199
|
+
SUM(CASE WHEN stars = 1 THEN 1 ELSE 0 END) as s1
|
|
200
|
+
FROM order_ratings r WHERE product_id = ? AND ${blindOpen}
|
|
201
|
+
`).get(req.params.product_id);
|
|
202
|
+
res.json({ items: rows, agg });
|
|
203
|
+
});
|
|
204
|
+
// 公开:卖家评价聚合(卖家主页)
|
|
205
|
+
app.get('/api/sellers/:seller_id/ratings', (req, res) => {
|
|
206
|
+
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 20));
|
|
207
|
+
const rows = db.prepare(`
|
|
208
|
+
SELECT r.stars, r.comment, r.reply, r.replied_at, r.buyer_followup, r.buyer_followup_at, r.created_at, r.product_id,
|
|
209
|
+
p.title as product_title,
|
|
210
|
+
u.name as buyer_name, u.handle as buyer_handle
|
|
211
|
+
FROM order_ratings r
|
|
212
|
+
JOIN products p ON p.id = r.product_id
|
|
213
|
+
JOIN users u ON u.id = r.buyer_id
|
|
214
|
+
WHERE r.seller_id = ?
|
|
215
|
+
ORDER BY r.created_at DESC LIMIT ?
|
|
216
|
+
`).all(req.params.seller_id, limit);
|
|
217
|
+
const agg = db.prepare(`SELECT COUNT(*) as cnt, COALESCE(AVG(stars), 0) as avg_stars FROM order_ratings WHERE seller_id = ?`).get(req.params.seller_id);
|
|
218
|
+
res.json({ items: rows, agg });
|
|
219
|
+
});
|
|
220
|
+
}
|