@seasonkoh/webaz 0.1.8 → 0.1.10
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 +3679 -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 +31362 -2320
- package/dist/pwa/public/i18n.js +5308 -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 +386 -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/ap2-mandate.js +121 -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 +161 -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 +106 -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 +382 -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 +227 -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 +9317 -2097
- package/package.json +8 -3
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
function median(arr) {
|
|
2
|
+
if (arr.length === 0)
|
|
3
|
+
return null;
|
|
4
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
5
|
+
const mid = Math.floor(s.length / 2);
|
|
6
|
+
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
7
|
+
}
|
|
8
|
+
export function registerAnalyticsRoutes(app, deps) {
|
|
9
|
+
const { db, auth } = deps;
|
|
10
|
+
// 物流绩效卡 (Wave B-4)
|
|
11
|
+
app.get('/api/logistics/me/performance', (req, res) => {
|
|
12
|
+
const user = auth(req, res);
|
|
13
|
+
if (!user)
|
|
14
|
+
return;
|
|
15
|
+
if (user.role !== 'logistics') {
|
|
16
|
+
return void res.status(403).json({ error: '仅物流角色可访问' });
|
|
17
|
+
}
|
|
18
|
+
const windowDays = Math.max(7, Math.min(365, Number(req.query.window) || 30));
|
|
19
|
+
const orders = db.prepare(`
|
|
20
|
+
SELECT id, status, created_at, updated_at,
|
|
21
|
+
pickup_deadline, delivery_deadline
|
|
22
|
+
FROM orders
|
|
23
|
+
WHERE logistics_id = ? AND created_at > datetime('now', '-' || ? || ' days')
|
|
24
|
+
`).all(user.id, windowDays);
|
|
25
|
+
const orderIds = orders.map(o => o.id);
|
|
26
|
+
let history = [];
|
|
27
|
+
if (orderIds.length > 0) {
|
|
28
|
+
const placeholders = orderIds.map(() => '?').join(',');
|
|
29
|
+
history = db.prepare(`
|
|
30
|
+
SELECT order_id, from_status, to_status, created_at
|
|
31
|
+
FROM order_state_history
|
|
32
|
+
WHERE order_id IN (${placeholders})
|
|
33
|
+
ORDER BY created_at ASC
|
|
34
|
+
`).all(...orderIds);
|
|
35
|
+
}
|
|
36
|
+
const histByOrder = new Map();
|
|
37
|
+
for (const h of history) {
|
|
38
|
+
if (!histByOrder.has(h.order_id))
|
|
39
|
+
histByOrder.set(h.order_id, []);
|
|
40
|
+
histByOrder.get(h.order_id).push(h);
|
|
41
|
+
}
|
|
42
|
+
let pickupOnTime = 0, pickupOverdue = 0;
|
|
43
|
+
let deliveryOnTime = 0, deliveryOverdue = 0;
|
|
44
|
+
let totalDelivered = 0, totalInTransit = 0, totalCompleted = 0;
|
|
45
|
+
const pickupDurationsHr = [];
|
|
46
|
+
const transitDurationsHr = [];
|
|
47
|
+
for (const o of orders) {
|
|
48
|
+
const h = histByOrder.get(o.id) || [];
|
|
49
|
+
const shipped = h.find(x => x.to_status === 'shipped');
|
|
50
|
+
const pickedUp = h.find(x => x.to_status === 'picked_up');
|
|
51
|
+
const delivered = h.find(x => x.to_status === 'delivered');
|
|
52
|
+
if (o.status === 'completed')
|
|
53
|
+
totalCompleted++;
|
|
54
|
+
if (o.status === 'in_transit' || o.status === 'picked_up')
|
|
55
|
+
totalInTransit++;
|
|
56
|
+
if (delivered)
|
|
57
|
+
totalDelivered++;
|
|
58
|
+
if (shipped && pickedUp) {
|
|
59
|
+
const hrs = (new Date(pickedUp.created_at).getTime() - new Date(shipped.created_at).getTime()) / 3600000;
|
|
60
|
+
if (hrs >= 0)
|
|
61
|
+
pickupDurationsHr.push(hrs);
|
|
62
|
+
if (o.pickup_deadline) {
|
|
63
|
+
if (new Date(pickedUp.created_at) <= new Date(o.pickup_deadline))
|
|
64
|
+
pickupOnTime++;
|
|
65
|
+
else
|
|
66
|
+
pickupOverdue++;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (pickedUp && delivered) {
|
|
70
|
+
const hrs = (new Date(delivered.created_at).getTime() - new Date(pickedUp.created_at).getTime()) / 3600000;
|
|
71
|
+
if (hrs >= 0)
|
|
72
|
+
transitDurationsHr.push(hrs);
|
|
73
|
+
if (o.delivery_deadline) {
|
|
74
|
+
if (new Date(delivered.created_at) <= new Date(o.delivery_deadline))
|
|
75
|
+
deliveryOnTime++;
|
|
76
|
+
else
|
|
77
|
+
deliveryOverdue++;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const disputes = db.prepare(`
|
|
82
|
+
SELECT COUNT(*) as n FROM disputes d
|
|
83
|
+
JOIN orders o ON o.id = d.order_id
|
|
84
|
+
WHERE o.logistics_id = ? AND d.created_at > datetime('now', '-' || ? || ' days')
|
|
85
|
+
`).get(user.id, windowDays).n;
|
|
86
|
+
// 败诉两路:auto-fault 判物流 + 仲裁裁定物流为被告且退款
|
|
87
|
+
const autoFaultLost = db.prepare(`
|
|
88
|
+
SELECT COUNT(*) as n FROM orders
|
|
89
|
+
WHERE logistics_id = ? AND status = 'fault_logistics'
|
|
90
|
+
AND updated_at > datetime('now', '-' || ? || ' days')
|
|
91
|
+
`).get(user.id, windowDays).n;
|
|
92
|
+
const arbitratedLost = db.prepare(`
|
|
93
|
+
SELECT COUNT(*) as n FROM disputes d
|
|
94
|
+
JOIN orders o ON o.id = d.order_id
|
|
95
|
+
WHERE o.logistics_id = ? AND d.defendant_id = ?
|
|
96
|
+
AND d.ruling_type IN ('refund_buyer','partial_refund')
|
|
97
|
+
AND d.created_at > datetime('now', '-' || ? || ' days')
|
|
98
|
+
`).get(user.id, user.id, windowDays).n;
|
|
99
|
+
const disputeLoss = autoFaultLost + arbitratedLost;
|
|
100
|
+
const pickupTotalEvaluated = pickupOnTime + pickupOverdue;
|
|
101
|
+
const deliveryTotalEvaluated = deliveryOnTime + deliveryOverdue;
|
|
102
|
+
res.json({
|
|
103
|
+
window_days: windowDays,
|
|
104
|
+
total_orders: orders.length,
|
|
105
|
+
in_progress: totalInTransit,
|
|
106
|
+
delivered: totalDelivered,
|
|
107
|
+
completed: totalCompleted,
|
|
108
|
+
pickup: {
|
|
109
|
+
on_time: pickupOnTime,
|
|
110
|
+
overdue: pickupOverdue,
|
|
111
|
+
on_time_rate: pickupTotalEvaluated > 0 ? pickupOnTime / pickupTotalEvaluated : null,
|
|
112
|
+
median_hours: median(pickupDurationsHr),
|
|
113
|
+
},
|
|
114
|
+
delivery: {
|
|
115
|
+
on_time: deliveryOnTime,
|
|
116
|
+
overdue: deliveryOverdue,
|
|
117
|
+
on_time_rate: deliveryTotalEvaluated > 0 ? deliveryOnTime / deliveryTotalEvaluated : null,
|
|
118
|
+
median_hours: median(transitDurationsHr),
|
|
119
|
+
},
|
|
120
|
+
disputes: {
|
|
121
|
+
total: disputes,
|
|
122
|
+
lost: disputeLoss,
|
|
123
|
+
loss_rate: disputes > 0 ? disputeLoss / disputes : null,
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
// 卖家销售分析 (Wave C-5)
|
|
128
|
+
app.get('/api/sellers/me/analytics', (req, res) => {
|
|
129
|
+
const user = auth(req, res);
|
|
130
|
+
if (!user)
|
|
131
|
+
return;
|
|
132
|
+
if (user.role !== 'seller')
|
|
133
|
+
return void res.status(403).json({ error: '仅卖家可访问' });
|
|
134
|
+
const windowDays = Math.max(7, Math.min(365, Number(req.query.window) || 30));
|
|
135
|
+
const ordersAgg = db.prepare(`
|
|
136
|
+
SELECT
|
|
137
|
+
COUNT(*) as total_orders,
|
|
138
|
+
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_orders,
|
|
139
|
+
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled_orders,
|
|
140
|
+
SUM(CASE WHEN status IN ('paid','accepted','shipped','picked_up','in_transit','delivered','confirmed') THEN 1 ELSE 0 END) as in_progress_orders,
|
|
141
|
+
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_amount ELSE 0 END), 0) as gmv,
|
|
142
|
+
COALESCE(AVG(CASE WHEN status = 'completed' THEN total_amount END), 0) as aov
|
|
143
|
+
FROM orders WHERE seller_id = ? AND created_at > datetime('now', '-' || ? || ' days')
|
|
144
|
+
`).get(user.id, windowDays);
|
|
145
|
+
const topProducts = db.prepare(`
|
|
146
|
+
SELECT p.id, p.title, p.price, COUNT(o.id) as sales,
|
|
147
|
+
COALESCE(SUM(o.total_amount), 0) as revenue
|
|
148
|
+
FROM products p
|
|
149
|
+
LEFT JOIN orders o ON o.product_id = p.id AND o.seller_id = p.seller_id
|
|
150
|
+
AND o.status = 'completed'
|
|
151
|
+
AND o.created_at > datetime('now', '-' || ? || ' days')
|
|
152
|
+
WHERE p.seller_id = ? AND p.status != 'deleted'
|
|
153
|
+
GROUP BY p.id
|
|
154
|
+
HAVING sales > 0
|
|
155
|
+
ORDER BY sales DESC LIMIT 10
|
|
156
|
+
`).all(windowDays, user.id);
|
|
157
|
+
const buyerStats = db.prepare(`
|
|
158
|
+
SELECT
|
|
159
|
+
COUNT(DISTINCT buyer_id) as unique_buyers,
|
|
160
|
+
COUNT(*) as orders_count
|
|
161
|
+
FROM orders WHERE seller_id = ? AND status = 'completed'
|
|
162
|
+
AND created_at > datetime('now', '-' || ? || ' days')
|
|
163
|
+
`).get(user.id, windowDays);
|
|
164
|
+
const repeatBuyers = db.prepare(`
|
|
165
|
+
SELECT COUNT(*) as n FROM (
|
|
166
|
+
SELECT buyer_id FROM orders WHERE seller_id = ? AND status = 'completed'
|
|
167
|
+
AND created_at > datetime('now', '-' || ? || ' days')
|
|
168
|
+
GROUP BY buyer_id HAVING COUNT(*) > 1
|
|
169
|
+
)
|
|
170
|
+
`).get(user.id, windowDays).n;
|
|
171
|
+
const wishlistAdds = db.prepare(`
|
|
172
|
+
SELECT COUNT(*) as n FROM user_wishlist w
|
|
173
|
+
JOIN products p ON p.id = w.product_id
|
|
174
|
+
WHERE p.seller_id = ? AND w.created_at > datetime('now', '-' || ? || ' days')
|
|
175
|
+
`).get(user.id, windowDays).n;
|
|
176
|
+
const dailyTrend = db.prepare(`
|
|
177
|
+
SELECT DATE(created_at) as date,
|
|
178
|
+
COUNT(*) as orders,
|
|
179
|
+
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_amount ELSE 0 END), 0) as gmv
|
|
180
|
+
FROM orders
|
|
181
|
+
WHERE seller_id = ? AND created_at > datetime('now', '-' || ? || ' days')
|
|
182
|
+
GROUP BY DATE(created_at)
|
|
183
|
+
ORDER BY date ASC
|
|
184
|
+
`).all(user.id, Math.min(windowDays, 30));
|
|
185
|
+
const ratingsAgg = db.prepare(`
|
|
186
|
+
SELECT COUNT(*) as cnt, COALESCE(AVG(stars), 0) as avg_stars
|
|
187
|
+
FROM order_ratings WHERE seller_id = ?
|
|
188
|
+
AND created_at > datetime('now', '-' || ? || ' days')
|
|
189
|
+
`).get(user.id, windowDays);
|
|
190
|
+
const refundsCount = db.prepare(`
|
|
191
|
+
SELECT COUNT(*) as n FROM return_requests
|
|
192
|
+
WHERE seller_id = ? AND status = 'refunded'
|
|
193
|
+
AND created_at > datetime('now', '-' || ? || ' days')
|
|
194
|
+
`).get(user.id, windowDays).n;
|
|
195
|
+
// S1: 平均备货时长(paid → shipped 中位 hours)
|
|
196
|
+
const handlingRow = db.prepare(`
|
|
197
|
+
SELECT COALESCE(AVG((julianday(h_ship.created_at) - julianday(h_paid.created_at)) * 24), 0) as avg_handling_hours,
|
|
198
|
+
COUNT(*) as sample_n
|
|
199
|
+
FROM orders o
|
|
200
|
+
JOIN order_state_history h_paid ON h_paid.order_id = o.id AND h_paid.to_status = 'paid'
|
|
201
|
+
JOIN order_state_history h_ship ON h_ship.order_id = o.id AND h_ship.to_status = 'shipped'
|
|
202
|
+
WHERE o.seller_id = ? AND o.created_at > datetime('now', '-' || ? || ' days')
|
|
203
|
+
`).get(user.id, windowDays);
|
|
204
|
+
const completedN = Number(ordersAgg.completed_orders) || 0;
|
|
205
|
+
const returnRate = completedN > 0 ? refundsCount / completedN : 0;
|
|
206
|
+
// S1: 上一窗口对比
|
|
207
|
+
const prevAgg = db.prepare(`
|
|
208
|
+
SELECT
|
|
209
|
+
COUNT(*) as total_orders,
|
|
210
|
+
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed_orders,
|
|
211
|
+
COALESCE(SUM(CASE WHEN status = 'completed' THEN total_amount ELSE 0 END), 0) as gmv
|
|
212
|
+
FROM orders WHERE seller_id = ?
|
|
213
|
+
AND created_at > datetime('now', '-' || ? || ' days')
|
|
214
|
+
AND created_at <= datetime('now', '-' || ? || ' days')
|
|
215
|
+
`).get(user.id, windowDays * 2, windowDays);
|
|
216
|
+
res.json({
|
|
217
|
+
window_days: windowDays,
|
|
218
|
+
orders: ordersAgg,
|
|
219
|
+
top_products: topProducts,
|
|
220
|
+
buyers: {
|
|
221
|
+
unique: buyerStats.unique_buyers,
|
|
222
|
+
repeat: repeatBuyers,
|
|
223
|
+
repeat_rate: buyerStats.unique_buyers > 0 ? repeatBuyers / buyerStats.unique_buyers : 0,
|
|
224
|
+
},
|
|
225
|
+
funnel: {
|
|
226
|
+
wishlist_adds: wishlistAdds,
|
|
227
|
+
orders: Number(ordersAgg.total_orders),
|
|
228
|
+
completed: Number(ordersAgg.completed_orders),
|
|
229
|
+
},
|
|
230
|
+
daily_trend: dailyTrend,
|
|
231
|
+
ratings: ratingsAgg,
|
|
232
|
+
refunds: refundsCount,
|
|
233
|
+
fulfillment: {
|
|
234
|
+
avg_handling_hours: Math.round(Number(handlingRow.avg_handling_hours) * 10) / 10,
|
|
235
|
+
sample_n: Number(handlingRow.sample_n),
|
|
236
|
+
},
|
|
237
|
+
quality: {
|
|
238
|
+
return_rate: Math.round(returnRate * 10000) / 10000,
|
|
239
|
+
refunds: refundsCount,
|
|
240
|
+
completed: completedN,
|
|
241
|
+
},
|
|
242
|
+
prev_window: {
|
|
243
|
+
total_orders: Number(prevAgg.total_orders),
|
|
244
|
+
completed_orders: Number(prevAgg.completed_orders),
|
|
245
|
+
gmv: Number(prevAgg.gmv),
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
// 卖家退货仪表盘
|
|
250
|
+
app.get('/api/sellers/me/return-stats', (req, res) => {
|
|
251
|
+
const user = auth(req, res);
|
|
252
|
+
if (!user)
|
|
253
|
+
return;
|
|
254
|
+
const totalReturns = db.prepare(`SELECT COUNT(*) as n FROM return_requests WHERE seller_id = ?`).get(user.id).n;
|
|
255
|
+
const refunded = db.prepare(`SELECT COUNT(*) as n FROM return_requests WHERE seller_id = ? AND status = 'refunded'`).get(user.id).n;
|
|
256
|
+
const rejected = db.prepare(`SELECT COUNT(*) as n FROM return_requests WHERE seller_id = ? AND status = 'rejected'`).get(user.id).n;
|
|
257
|
+
const pending = db.prepare(`SELECT COUNT(*) as n FROM return_requests WHERE seller_id = ? AND status = 'pending'`).get(user.id).n;
|
|
258
|
+
const totalOrders = db.prepare(`SELECT COUNT(*) as n FROM orders WHERE seller_id = ? AND status IN ('delivered','completed','refunded')`).get(user.id).n;
|
|
259
|
+
const reasonBreakdown = db.prepare(`
|
|
260
|
+
SELECT reason, COUNT(*) as cnt FROM return_requests
|
|
261
|
+
WHERE seller_id = ? GROUP BY reason ORDER BY cnt DESC
|
|
262
|
+
`).all(user.id);
|
|
263
|
+
const returnRate = totalOrders > 0 ? (refunded / totalOrders) : 0;
|
|
264
|
+
res.json({
|
|
265
|
+
total_returns: totalReturns,
|
|
266
|
+
refunded, rejected, pending,
|
|
267
|
+
total_orders: totalOrders,
|
|
268
|
+
return_rate: returnRate,
|
|
269
|
+
reason_breakdown: reasonBreakdown,
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { generateAnchor, lookupAnchor, retireAnchor, userReferralVolume, computeTierLetter, userAnchorQuotaStats, TIER_THRESHOLDS, ANCHOR_HANDLE_MAX_FOR_USE, } from '../../layer2-business/L2-anchor-registry/anchor-registry.js';
|
|
2
|
+
export function registerAnchorsRoutes(app, deps) {
|
|
3
|
+
const { db, auth, rateLimitOk } = deps;
|
|
4
|
+
// POST /api/anchor/generate
|
|
5
|
+
app.post('/api/anchor/generate', (req, res) => {
|
|
6
|
+
const user = auth(req, res);
|
|
7
|
+
if (!user)
|
|
8
|
+
return;
|
|
9
|
+
if (!rateLimitOk(req.ip || 'anon', 10, 60_000))
|
|
10
|
+
return void res.status(429).json({ error: '生成过于频繁' });
|
|
11
|
+
const { middle, target_kind, target_id } = req.body || {};
|
|
12
|
+
if (!middle || !target_kind || !target_id)
|
|
13
|
+
return void res.status(400).json({ error: 'middle / target_kind / target_id 必填' });
|
|
14
|
+
if (!['user', 'product', 'shareable', 'dispute_case'].includes(target_kind)) {
|
|
15
|
+
return void res.status(400).json({ error: 'target_kind 仅允许 user / product / shareable / dispute_case' });
|
|
16
|
+
}
|
|
17
|
+
const r = generateAnchor(db, {
|
|
18
|
+
ownerId: user.id,
|
|
19
|
+
middle: String(middle),
|
|
20
|
+
targetKind: target_kind,
|
|
21
|
+
targetId: String(target_id),
|
|
22
|
+
});
|
|
23
|
+
if (!r.ok)
|
|
24
|
+
return void res.status(400).json({ error: r.reason });
|
|
25
|
+
res.json({ ok: true, anchor: r.anchor, tier_letter: r.tier_letter });
|
|
26
|
+
});
|
|
27
|
+
// GET /api/anchor/:code/lookup — 公开(无需 auth)
|
|
28
|
+
app.get('/api/anchor/:code/lookup', (req, res) => {
|
|
29
|
+
if (!rateLimitOk(req.ip || 'anon', 60, 60_000))
|
|
30
|
+
return void res.status(429).json({ error: 'too_many_lookups' });
|
|
31
|
+
const r = lookupAnchor(db, String(req.params.code || ''));
|
|
32
|
+
if (!r.found)
|
|
33
|
+
return void res.status(404).json({ found: false });
|
|
34
|
+
if (r.status === 'retired') {
|
|
35
|
+
return void res.status(410).json({ found: true, status: 'retired', retired_at: r.retired_at, owner_id: r.owner_id, tier_letter: r.tier_letter, hint: 'archived' });
|
|
36
|
+
}
|
|
37
|
+
if (r.status === 'reclaimable') {
|
|
38
|
+
return void res.status(404).json({ found: false, hint: 'reclaimable' });
|
|
39
|
+
}
|
|
40
|
+
// 2026-05-24 富化响应:附 owner 详情 + 商品推荐指数
|
|
41
|
+
const owner = db.prepare(`
|
|
42
|
+
SELECT u.name, u.handle, u.region, u.created_at, u.bio,
|
|
43
|
+
(SELECT COUNT(*) FROM follows WHERE followee_id = u.id) as follower_count,
|
|
44
|
+
(SELECT COALESCE(SUM(s.like_count), 0) FROM shareables s WHERE s.owner_id = u.id AND s.status = 'active') as total_likes_received
|
|
45
|
+
FROM users u WHERE u.id = ? AND u.id != 'sys_protocol'
|
|
46
|
+
`).get(r.owner_id);
|
|
47
|
+
let product = null;
|
|
48
|
+
if (r.target_kind === 'product') {
|
|
49
|
+
product = db.prepare(`
|
|
50
|
+
SELECT p.id, p.title, p.price, p.category, p.images, p.completion_count, p.total_likes,
|
|
51
|
+
(SELECT COUNT(DISTINCT buyer_id) FROM order_ratings rt WHERE rt.product_id = p.id AND rt.stars >= 4) as recommend_count,
|
|
52
|
+
(SELECT ROUND(AVG(stars), 2) FROM order_ratings rt WHERE rt.product_id = p.id) as avg_rating,
|
|
53
|
+
(SELECT COUNT(*) FROM order_ratings rt WHERE rt.product_id = p.id) as rating_count,
|
|
54
|
+
u.handle as seller_handle, u.name as seller_name
|
|
55
|
+
FROM products p LEFT JOIN users u ON u.id = p.seller_id
|
|
56
|
+
WHERE p.id = ? AND p.status = 'active'
|
|
57
|
+
`).get(r.target_id);
|
|
58
|
+
}
|
|
59
|
+
else if (r.target_kind === 'shareable') {
|
|
60
|
+
const sh = db.prepare(`SELECT related_product_id FROM shareables WHERE id = ?`).get(r.target_id);
|
|
61
|
+
if (sh?.related_product_id) {
|
|
62
|
+
product = db.prepare(`
|
|
63
|
+
SELECT p.id, p.title, p.price, p.category, p.images, p.completion_count, p.total_likes,
|
|
64
|
+
(SELECT COUNT(DISTINCT buyer_id) FROM order_ratings rt WHERE rt.product_id = p.id AND rt.stars >= 4) as recommend_count,
|
|
65
|
+
(SELECT ROUND(AVG(stars), 2) FROM order_ratings rt WHERE rt.product_id = p.id) as avg_rating,
|
|
66
|
+
(SELECT COUNT(*) FROM order_ratings rt WHERE rt.product_id = p.id) as rating_count,
|
|
67
|
+
u.handle as seller_handle, u.name as seller_name
|
|
68
|
+
FROM products p LEFT JOIN users u ON u.id = p.seller_id
|
|
69
|
+
WHERE p.id = ? AND p.status = 'active'
|
|
70
|
+
`).get(sh.related_product_id);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
res.json({
|
|
74
|
+
found: true, status: 'active',
|
|
75
|
+
target_kind: r.target_kind,
|
|
76
|
+
target_id: r.target_id,
|
|
77
|
+
owner_id: r.owner_id,
|
|
78
|
+
tier_letter: r.tier_letter,
|
|
79
|
+
owner,
|
|
80
|
+
product,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
// POST /api/anchor/:code/touch — 写 attribution(first-touch + 30d)
|
|
84
|
+
app.post('/api/anchor/:code/touch', (req, res) => {
|
|
85
|
+
const user = auth(req, res);
|
|
86
|
+
if (!user)
|
|
87
|
+
return;
|
|
88
|
+
const r = lookupAnchor(db, String(req.params.code || ''));
|
|
89
|
+
if (!r.found || r.status !== 'active')
|
|
90
|
+
return void res.status(404).json({ error: 'anchor_not_active' });
|
|
91
|
+
if (r.owner_id === user.id)
|
|
92
|
+
return void res.json({ ok: true, skipped: 'self_anchor' });
|
|
93
|
+
let attributedProducts = 0;
|
|
94
|
+
const expiresAt = new Date(Date.now() + 30 * 86400_000).toISOString().slice(0, 19).replace('T', ' ');
|
|
95
|
+
if (r.target_kind === 'product') {
|
|
96
|
+
const existing = db.prepare(`SELECT 1 FROM product_share_attribution WHERE product_id = ? AND recipient_id = ?`).get(r.target_id, user.id);
|
|
97
|
+
if (!existing) {
|
|
98
|
+
db.prepare(`INSERT INTO product_share_attribution (product_id, recipient_id, sharer_id, shareable_id, expires_at) VALUES (?,?,?,NULL,?)`)
|
|
99
|
+
.run(r.target_id, user.id, r.owner_id, expiresAt);
|
|
100
|
+
attributedProducts = 1;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else if (r.target_kind === 'shareable') {
|
|
104
|
+
const s = db.prepare(`SELECT id, related_product_id FROM shareables WHERE id = ?`).get(r.target_id);
|
|
105
|
+
if (s?.related_product_id) {
|
|
106
|
+
const existing = db.prepare(`SELECT 1 FROM product_share_attribution WHERE product_id = ? AND recipient_id = ?`).get(s.related_product_id, user.id);
|
|
107
|
+
if (!existing) {
|
|
108
|
+
db.prepare(`INSERT INTO product_share_attribution (product_id, recipient_id, sharer_id, shareable_id, expires_at) VALUES (?,?,?,?,?)`)
|
|
109
|
+
.run(s.related_product_id, user.id, r.owner_id, s.id, expiresAt);
|
|
110
|
+
attributedProducts = 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else if (r.target_kind === 'user') {
|
|
115
|
+
// 限 LIMIT 50 防 DoS
|
|
116
|
+
const ownerProducts = db.prepare(`SELECT id FROM products WHERE seller_id = ? AND status = 'active' ORDER BY last_sold_at DESC NULLS LAST LIMIT 50`).all(r.owner_id);
|
|
117
|
+
db.transaction(() => {
|
|
118
|
+
for (const p of ownerProducts) {
|
|
119
|
+
const existing = db.prepare(`SELECT 1 FROM product_share_attribution WHERE product_id = ? AND recipient_id = ?`).get(p.id, user.id);
|
|
120
|
+
if (existing)
|
|
121
|
+
continue;
|
|
122
|
+
db.prepare(`INSERT INTO product_share_attribution (product_id, recipient_id, sharer_id, shareable_id, expires_at) VALUES (?,?,?,NULL,?)`)
|
|
123
|
+
.run(p.id, user.id, r.owner_id, expiresAt);
|
|
124
|
+
attributedProducts++;
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
}
|
|
128
|
+
// dispute_case:无商业 attribution
|
|
129
|
+
res.json({
|
|
130
|
+
ok: true,
|
|
131
|
+
target_kind: r.target_kind,
|
|
132
|
+
target_id: r.target_id,
|
|
133
|
+
owner_id: r.owner_id,
|
|
134
|
+
tier_letter: r.tier_letter,
|
|
135
|
+
attributed_products: attributedProducts,
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
app.post('/api/anchor/:code/retire', (req, res) => {
|
|
139
|
+
const user = auth(req, res);
|
|
140
|
+
if (!user)
|
|
141
|
+
return;
|
|
142
|
+
const r = retireAnchor(db, user.id, String(req.params.code || ''));
|
|
143
|
+
if (!r.ok) {
|
|
144
|
+
const status = r.reason === 'not_found' ? 404 : r.reason === 'not_owner' ? 403 : 400;
|
|
145
|
+
return void res.status(status).json({ error: r.reason });
|
|
146
|
+
}
|
|
147
|
+
res.json({ ok: true });
|
|
148
|
+
});
|
|
149
|
+
app.get('/api/anchor/me', (req, res) => {
|
|
150
|
+
const user = auth(req, res);
|
|
151
|
+
if (!user)
|
|
152
|
+
return;
|
|
153
|
+
const rows = db.prepare(`
|
|
154
|
+
SELECT anchor, prefix, middle, tier_letter, target_kind, target_id, status, retired_at, hits, last_hit_at, created_at
|
|
155
|
+
FROM anchor_registry WHERE owner_id = ? ORDER BY created_at DESC LIMIT 100
|
|
156
|
+
`).all(user.id);
|
|
157
|
+
const vol = userReferralVolume(db, user.id);
|
|
158
|
+
const tier = computeTierLetter(vol);
|
|
159
|
+
const quota = userAnchorQuotaStats(db, user.id);
|
|
160
|
+
res.json({
|
|
161
|
+
items: rows,
|
|
162
|
+
current_tier: tier,
|
|
163
|
+
referral_volume: vol,
|
|
164
|
+
handle_max_for_anchor: ANCHOR_HANDLE_MAX_FOR_USE,
|
|
165
|
+
tier_thresholds: TIER_THRESHOLDS,
|
|
166
|
+
quota,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
export function registerAnnouncementsRoutes(app, deps) {
|
|
2
|
+
const { db, generateId, auth, safeRoles, requireProtocolAdmin, isRootAdmin, getAdminScope, logAdminAction } = deps;
|
|
3
|
+
app.post('/api/admin/announcements', (req, res) => {
|
|
4
|
+
const admin = requireProtocolAdmin(req, res);
|
|
5
|
+
if (!admin)
|
|
6
|
+
return;
|
|
7
|
+
const { title, body, target_roles, target_regions, severity, starts_at, expires_at } = req.body || {};
|
|
8
|
+
if (!title?.trim() || title.length > 100)
|
|
9
|
+
return void res.status(400).json({ error: 'title 1-100 字' });
|
|
10
|
+
if (!body?.trim() || body.length > 2000)
|
|
11
|
+
return void res.status(400).json({ error: 'body 1-2000 字' });
|
|
12
|
+
if (severity && !['info', 'warning', 'critical'].includes(severity))
|
|
13
|
+
return void res.status(400).json({ error: 'severity 须为 info / warning / critical' });
|
|
14
|
+
const rolesJson = Array.isArray(target_roles) && target_roles.length > 0 ? JSON.stringify(target_roles) : null;
|
|
15
|
+
const regionsJson = Array.isArray(target_regions) && target_regions.length > 0 ? JSON.stringify(target_regions) : null;
|
|
16
|
+
// 区域 admin 只能发本区域
|
|
17
|
+
if (!isRootAdmin(admin)) {
|
|
18
|
+
const scope = getAdminScope(admin);
|
|
19
|
+
if (regionsJson) {
|
|
20
|
+
const parsedRegions = JSON.parse(regionsJson);
|
|
21
|
+
if (!parsedRegions.every(r => r === scope))
|
|
22
|
+
return void res.status(403).json({ error: `区域 admin 仅可发本区域 (${scope}) 公告` });
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
return void res.status(400).json({ error: `区域 admin 必须指定 target_regions: ['${scope}']` });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const id = generateId('ann');
|
|
29
|
+
db.prepare(`INSERT INTO announcements (id, author_id, title, body, target_roles, target_regions, severity, starts_at, expires_at) VALUES (?,?,?,?,?,?,?,?,?)`)
|
|
30
|
+
.run(id, admin.id, title.trim(), body.trim(), rolesJson, regionsJson, severity || 'info', starts_at || null, expires_at || null);
|
|
31
|
+
logAdminAction(admin.id, 'create_announcement', 'announcement', id, { title, severity: severity || 'info' });
|
|
32
|
+
res.json({ success: true, id });
|
|
33
|
+
});
|
|
34
|
+
app.patch('/api/admin/announcements/:id', (req, res) => {
|
|
35
|
+
const admin = requireProtocolAdmin(req, res);
|
|
36
|
+
if (!admin)
|
|
37
|
+
return;
|
|
38
|
+
const ann = db.prepare('SELECT id, author_id FROM announcements WHERE id = ?').get(req.params.id);
|
|
39
|
+
if (!ann)
|
|
40
|
+
return void res.status(404).json({ error: '公告不存在' });
|
|
41
|
+
if (!isRootAdmin(admin) && ann.author_id !== admin.id)
|
|
42
|
+
return void res.status(403).json({ error: '仅可编辑自己发的公告(root 可全管)' });
|
|
43
|
+
const { is_active, expires_at } = req.body || {};
|
|
44
|
+
const sets = [];
|
|
45
|
+
const args = [];
|
|
46
|
+
if (is_active !== undefined) {
|
|
47
|
+
sets.push('is_active = ?');
|
|
48
|
+
args.push(is_active ? 1 : 0);
|
|
49
|
+
}
|
|
50
|
+
if (expires_at !== undefined) {
|
|
51
|
+
sets.push('expires_at = ?');
|
|
52
|
+
args.push(expires_at);
|
|
53
|
+
}
|
|
54
|
+
if (sets.length === 0)
|
|
55
|
+
return void res.status(400).json({ error: '无可更新字段' });
|
|
56
|
+
args.push(req.params.id);
|
|
57
|
+
db.prepare(`UPDATE announcements SET ${sets.join(', ')} WHERE id = ?`).run(...args);
|
|
58
|
+
res.json({ success: true });
|
|
59
|
+
});
|
|
60
|
+
// 列出对当前用户可见的活跃公告(按角色 + 区域过滤)
|
|
61
|
+
app.get('/api/announcements/active', (req, res) => {
|
|
62
|
+
const user = auth(req, res);
|
|
63
|
+
if (!user)
|
|
64
|
+
return;
|
|
65
|
+
const userRoles = safeRoles(user);
|
|
66
|
+
const userRegion = user.region || 'global';
|
|
67
|
+
const rows = db.prepare(`
|
|
68
|
+
SELECT a.id, a.title, a.body, a.severity, a.created_at, a.target_roles, a.target_regions,
|
|
69
|
+
(SELECT 1 FROM announcement_reads WHERE user_id = ? AND announcement_id = a.id) as is_read
|
|
70
|
+
FROM announcements a
|
|
71
|
+
WHERE a.is_active = 1
|
|
72
|
+
AND (a.starts_at IS NULL OR a.starts_at <= datetime('now'))
|
|
73
|
+
AND (a.expires_at IS NULL OR a.expires_at >= datetime('now'))
|
|
74
|
+
ORDER BY a.created_at DESC LIMIT 50
|
|
75
|
+
`).all(user.id);
|
|
76
|
+
// JS 端 filter 角色 / 区域(避免 JSON LIKE 在 SQLite 中麻烦)
|
|
77
|
+
const filtered = rows.filter(a => {
|
|
78
|
+
if (a.target_roles) {
|
|
79
|
+
try {
|
|
80
|
+
const tr = JSON.parse(a.target_roles);
|
|
81
|
+
const matches = tr.some(r => userRoles.includes(r) || user.role === r);
|
|
82
|
+
if (!matches)
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
catch { }
|
|
86
|
+
}
|
|
87
|
+
if (a.target_regions) {
|
|
88
|
+
try {
|
|
89
|
+
const tg = JSON.parse(a.target_regions);
|
|
90
|
+
if (!tg.includes(userRegion))
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
catch { }
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}).map(a => ({ ...a, target_roles: undefined, target_regions: undefined, is_read: !!a.is_read }));
|
|
97
|
+
res.json({ items: filtered });
|
|
98
|
+
});
|
|
99
|
+
app.post('/api/announcements/:id/read', (req, res) => {
|
|
100
|
+
const user = auth(req, res);
|
|
101
|
+
if (!user)
|
|
102
|
+
return;
|
|
103
|
+
try {
|
|
104
|
+
db.prepare(`INSERT OR IGNORE INTO announcement_reads (user_id, announcement_id) VALUES (?,?)`)
|
|
105
|
+
.run(user.id, req.params.id);
|
|
106
|
+
}
|
|
107
|
+
catch { }
|
|
108
|
+
res.json({ success: true });
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AP2 Mandate 构造器 — 把 webaz 内部 verify_price / place_order 输出
|
|
3
|
+
* 映射为 Google AP2 (Agent Payments Protocol) 的三类 Mandate 结构,
|
|
4
|
+
* 与 webaz 原 schema 并存(不破坏现有客户端),让 AP2-aware 的 agent 可直接消费。
|
|
5
|
+
*
|
|
6
|
+
* AP2 三类 Mandate:
|
|
7
|
+
* IntentMandate — 用户/agent "想买什么"(verify_price 锁价时签发)
|
|
8
|
+
* CartMandate — agent 实际组装的购物车(place_order 成功时签发)
|
|
9
|
+
* PaymentMandate — 卖家应收金额(place_order 成功时签发,与 Cart 并发)
|
|
10
|
+
*
|
|
11
|
+
* 签名方案 = Phase 4 同款 eip191(hot wallet),signature 字段由调用方填充。
|
|
12
|
+
* canonical 是用于签名的 JSON.stringify 输出(确定序列化、键序固定)。
|
|
13
|
+
*/
|
|
14
|
+
const AP2_VERSION = '1.0';
|
|
15
|
+
const AP2_SPEC = 'https://github.com/google-agentic-commerce/AP2';
|
|
16
|
+
function nowIso() { return new Date().toISOString(); }
|
|
17
|
+
function canonical(obj) {
|
|
18
|
+
// 简单深排序后 JSON.stringify;够用于签名稳定性,不引入额外依赖。
|
|
19
|
+
const sort = (v) => {
|
|
20
|
+
if (v === null || typeof v !== 'object')
|
|
21
|
+
return v;
|
|
22
|
+
if (Array.isArray(v))
|
|
23
|
+
return v.map(sort);
|
|
24
|
+
return Object.keys(v).sort().reduce((acc, k) => {
|
|
25
|
+
acc[k] = sort(v[k]);
|
|
26
|
+
return acc;
|
|
27
|
+
}, {});
|
|
28
|
+
};
|
|
29
|
+
return JSON.stringify(sort(obj));
|
|
30
|
+
}
|
|
31
|
+
function baseProof(input) {
|
|
32
|
+
return {
|
|
33
|
+
type: 'EcdsaSecp256k1RecoverySignature2020',
|
|
34
|
+
scheme: 'eip191',
|
|
35
|
+
proofPurpose: 'assertionMethod',
|
|
36
|
+
verificationMethod: `${input.issuerDid}#controller`,
|
|
37
|
+
blockchainAccountId: `eip155:8453:${input.issuerAddress}`,
|
|
38
|
+
created: input.issuedAt ?? nowIso(),
|
|
39
|
+
signature: '', // 调用方填充
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function buildIntentMandate(input) {
|
|
43
|
+
const issuedAt = input.issuedAt ?? nowIso();
|
|
44
|
+
const mandate = {
|
|
45
|
+
'@context': ['https://www.w3.org/2018/credentials/v1', AP2_SPEC],
|
|
46
|
+
type: ['VerifiableCredential', 'IntentMandate'],
|
|
47
|
+
ap2_version: AP2_VERSION,
|
|
48
|
+
issuer: input.issuerDid,
|
|
49
|
+
issuedAt,
|
|
50
|
+
...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
|
|
51
|
+
principal: input.principal,
|
|
52
|
+
intent: {
|
|
53
|
+
action: 'purchase',
|
|
54
|
+
target: { type: 'product', sku: input.productId, ...(input.productName ? { name: input.productName } : {}) },
|
|
55
|
+
constraints: {
|
|
56
|
+
max_unit_price: input.maxUnitPrice,
|
|
57
|
+
quantity: input.quantity,
|
|
58
|
+
max_total: input.maxUnitPrice * input.quantity,
|
|
59
|
+
currency: input.currency,
|
|
60
|
+
},
|
|
61
|
+
session_token: input.sessionToken,
|
|
62
|
+
},
|
|
63
|
+
proof: baseProof({ ...input, issuedAt }),
|
|
64
|
+
};
|
|
65
|
+
// canonical 签名内容 = 除 proof.signature 外的全部字段
|
|
66
|
+
const forSign = JSON.parse(JSON.stringify(mandate));
|
|
67
|
+
forSign.proof.signature = '';
|
|
68
|
+
return { canonical: canonical(forSign), mandate };
|
|
69
|
+
}
|
|
70
|
+
export function buildCartMandate(input) {
|
|
71
|
+
const issuedAt = input.issuedAt ?? nowIso();
|
|
72
|
+
const mandate = {
|
|
73
|
+
'@context': ['https://www.w3.org/2018/credentials/v1', AP2_SPEC],
|
|
74
|
+
type: ['VerifiableCredential', 'CartMandate'],
|
|
75
|
+
ap2_version: AP2_VERSION,
|
|
76
|
+
issuer: input.issuerDid,
|
|
77
|
+
issuedAt,
|
|
78
|
+
principal: input.principal,
|
|
79
|
+
cart: {
|
|
80
|
+
order_id: input.orderId,
|
|
81
|
+
items: input.items,
|
|
82
|
+
subtotal: input.subtotal,
|
|
83
|
+
...(input.fees ? { fees: input.fees } : {}),
|
|
84
|
+
total: input.total,
|
|
85
|
+
currency: input.currency,
|
|
86
|
+
},
|
|
87
|
+
proof: baseProof({ ...input, issuedAt }),
|
|
88
|
+
};
|
|
89
|
+
const forSign = JSON.parse(JSON.stringify(mandate));
|
|
90
|
+
forSign.proof.signature = '';
|
|
91
|
+
return { canonical: canonical(forSign), mandate };
|
|
92
|
+
}
|
|
93
|
+
export function buildPaymentMandate(input) {
|
|
94
|
+
const issuedAt = input.issuedAt ?? nowIso();
|
|
95
|
+
const mandate = {
|
|
96
|
+
'@context': ['https://www.w3.org/2018/credentials/v1', AP2_SPEC],
|
|
97
|
+
type: ['VerifiableCredential', 'PaymentMandate'],
|
|
98
|
+
ap2_version: AP2_VERSION,
|
|
99
|
+
issuer: input.issuerDid,
|
|
100
|
+
issuedAt,
|
|
101
|
+
payer: input.payer,
|
|
102
|
+
payee: input.payee,
|
|
103
|
+
payment: {
|
|
104
|
+
amount: input.amount,
|
|
105
|
+
currency: input.currency,
|
|
106
|
+
method: input.paymentMethod,
|
|
107
|
+
order_id: input.orderId,
|
|
108
|
+
...(input.escrowReleaseCondition ? { release_condition: input.escrowReleaseCondition } : {}),
|
|
109
|
+
},
|
|
110
|
+
proof: baseProof({ ...input, issuedAt }),
|
|
111
|
+
};
|
|
112
|
+
const forSign = JSON.parse(JSON.stringify(mandate));
|
|
113
|
+
forSign.proof.signature = '';
|
|
114
|
+
return { canonical: canonical(forSign), mandate };
|
|
115
|
+
}
|
|
116
|
+
/** 把 build*Mandate 输出的 canonical 经 signFn 签名后,塞回 mandate.proof.signature */
|
|
117
|
+
export async function signMandate(out, signFn) {
|
|
118
|
+
const sig = await signFn(out.canonical);
|
|
119
|
+
out.mandate.proof.signature = sig;
|
|
120
|
+
return out.mandate;
|
|
121
|
+
}
|