@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,101 @@
|
|
|
1
|
+
export function registerAiRoutes(app, deps) {
|
|
2
|
+
const { db, auth, anthropic } = deps;
|
|
3
|
+
// G-2: AI 价格建议
|
|
4
|
+
app.post('/api/ai/price-suggestion', async (req, res) => {
|
|
5
|
+
const user = auth(req, res);
|
|
6
|
+
if (!user)
|
|
7
|
+
return;
|
|
8
|
+
if (user.role !== 'seller')
|
|
9
|
+
return void res.status(403).json({ error: '仅卖家可用' });
|
|
10
|
+
const { title, category, description } = req.body || {};
|
|
11
|
+
if (!title)
|
|
12
|
+
return void res.status(400).json({ error: '请提供 title' });
|
|
13
|
+
// 类目历史价位
|
|
14
|
+
const stats = db.prepare(`
|
|
15
|
+
SELECT COUNT(*) as cnt, COALESCE(AVG(price), 0) as avg, COALESCE(MIN(price), 0) as min, COALESCE(MAX(price), 0) as max,
|
|
16
|
+
COALESCE((SELECT price FROM products WHERE status='active' AND category = ? ORDER BY price LIMIT 1 OFFSET CAST((SELECT COUNT(*) FROM products WHERE status='active' AND category = ?) / 2 AS INTEGER)), 0) as median
|
|
17
|
+
FROM products WHERE status = 'active' AND category = ?
|
|
18
|
+
`).get(category || '', category || '', category || '');
|
|
19
|
+
// 近 30 天成交均价(更可信)
|
|
20
|
+
const recentAvg = db.prepare(`
|
|
21
|
+
SELECT COALESCE(AVG(total_amount), 0) as avg FROM orders o
|
|
22
|
+
JOIN products p ON p.id = o.product_id
|
|
23
|
+
WHERE p.category = ? AND o.status = 'completed' AND o.created_at > datetime('now', '-30 days')
|
|
24
|
+
`).get(category || '').avg;
|
|
25
|
+
try {
|
|
26
|
+
const message = await anthropic.messages.create({
|
|
27
|
+
model: 'claude-haiku-4-5-20251001',
|
|
28
|
+
max_tokens: 400,
|
|
29
|
+
messages: [{
|
|
30
|
+
role: 'user',
|
|
31
|
+
content: `你是 WebAZ 定价顾问。给以下商品建议合理定价(WAZ ≈ CNY,1 USDC ≈ 1 WAZ):
|
|
32
|
+
商品标题: ${String(title).slice(0, 100)}
|
|
33
|
+
类目: ${category || '未填'}
|
|
34
|
+
描述: ${String(description || '').slice(0, 300)}
|
|
35
|
+
|
|
36
|
+
同类目市场数据(active 商品):
|
|
37
|
+
- 商品数: ${stats.cnt}
|
|
38
|
+
- 价位区间: ${stats.min} - ${stats.max} WAZ
|
|
39
|
+
- 均价: ${stats.avg.toFixed(0)} WAZ
|
|
40
|
+
- 中位价: ${stats.median} WAZ
|
|
41
|
+
- 近 30 天成交均价: ${recentAvg.toFixed(0)} WAZ
|
|
42
|
+
|
|
43
|
+
只返回 JSON(无前后缀):
|
|
44
|
+
{
|
|
45
|
+
"suggested_price": 推荐价数字,
|
|
46
|
+
"low_price": 价格区间下限,
|
|
47
|
+
"high_price": 价格区间上限,
|
|
48
|
+
"reasoning": "1-2 句简短解释"
|
|
49
|
+
}`,
|
|
50
|
+
}],
|
|
51
|
+
});
|
|
52
|
+
const text = message.content[0]?.text || '';
|
|
53
|
+
const m = text.match(/\{[\s\S]*\}/);
|
|
54
|
+
if (!m)
|
|
55
|
+
return void res.status(500).json({ error: 'AI 返回格式错误' });
|
|
56
|
+
const parsed = JSON.parse(m[0]);
|
|
57
|
+
res.json({ ...parsed, market_data: stats, recent_avg: recentAvg });
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
res.status(503).json({ error: 'AI 失败: ' + e.message });
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
// G-1: AI 文案生成(卖家发品辅助)
|
|
64
|
+
app.post('/api/ai/generate-description', async (req, res) => {
|
|
65
|
+
const user = auth(req, res);
|
|
66
|
+
if (!user)
|
|
67
|
+
return;
|
|
68
|
+
if (user.role !== 'seller')
|
|
69
|
+
return void res.status(403).json({ error: '仅卖家可用' });
|
|
70
|
+
const { title, category, keywords, language } = req.body || {};
|
|
71
|
+
if (!title)
|
|
72
|
+
return void res.status(400).json({ error: '请提供 title' });
|
|
73
|
+
const lang = language === 'en' ? 'English' : '中文';
|
|
74
|
+
try {
|
|
75
|
+
const message = await anthropic.messages.create({
|
|
76
|
+
model: 'claude-haiku-4-5-20251001',
|
|
77
|
+
max_tokens: 600,
|
|
78
|
+
messages: [{
|
|
79
|
+
role: 'user',
|
|
80
|
+
content: `你是 WebAZ 电商文案助手。根据以下信息生成商品描述(${lang}):
|
|
81
|
+
- 标题: ${String(title).slice(0, 100)}
|
|
82
|
+
- 类目: ${category || '未填'}
|
|
83
|
+
- 关键词: ${(keywords || []).slice(0, 10).join('、') || '无'}
|
|
84
|
+
|
|
85
|
+
要求:
|
|
86
|
+
1. 100-200 字
|
|
87
|
+
2. 强调 1-2 个差异化卖点
|
|
88
|
+
3. 无虚假宣传 / 无绝对化用语(最、第一)
|
|
89
|
+
4. ${lang}
|
|
90
|
+
5. 不加 emoji
|
|
91
|
+
6. 直接输出文案正文,无多余前后缀`,
|
|
92
|
+
}],
|
|
93
|
+
});
|
|
94
|
+
const text = message.content[0]?.text || '';
|
|
95
|
+
res.json({ description: text.trim(), model: 'claude-haiku-4-5' });
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
res.status(503).json({ error: 'AI 生成失败: ' + e.message });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -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
|
+
}
|