@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,470 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { writeNotePhoto, readNotePhoto, noteBlobExists, NOTE_PHOTO_MAX_BYTES, NOTE_PHOTO_ALLOWED_MIME } from '../../layer2-business/L2-notes/note-photo-storage.js';
|
|
3
|
+
import { retireAnchorsByTarget } from '../../layer2-business/L2-anchor-registry/anchor-registry.js';
|
|
4
|
+
export const SHAREABLE_DAILY_LIMIT = 10;
|
|
5
|
+
export function registerShareablesRoutes(app, deps) {
|
|
6
|
+
const { db, auth, getUser, generateId, lightAuthGuard, detectExternalPlatform, noteAuthenticityBadges, parseHashtags, parseMentions, notifyMentions, flagNewAccountShareable, refreshProductSharerCount } = deps;
|
|
7
|
+
// Phase C2 笔记图片上传 — raw blob,sha256 重算,返回 hash + dedup
|
|
8
|
+
app.post('/api/notes/photo', lightAuthGuard, express.raw({ type: 'application/octet-stream', limit: NOTE_PHOTO_MAX_BYTES }), (req, res) => {
|
|
9
|
+
const user = auth(req, res);
|
|
10
|
+
if (!user)
|
|
11
|
+
return;
|
|
12
|
+
const hash = String(req.headers['x-content-hash'] || '').trim().toLowerCase();
|
|
13
|
+
const mime = String(req.headers['x-content-mime'] || '').trim().toLowerCase();
|
|
14
|
+
if (!/^[0-9a-f]{64}$/.test(hash))
|
|
15
|
+
return void res.status(400).json({ error: 'invalid_hash' });
|
|
16
|
+
if (!NOTE_PHOTO_ALLOWED_MIME.has(mime))
|
|
17
|
+
return void res.status(415).json({ error: 'mime_not_allowed', allowed: [...NOTE_PHOTO_ALLOWED_MIME] });
|
|
18
|
+
const blob = req.body;
|
|
19
|
+
if (!Buffer.isBuffer(blob) || blob.length === 0)
|
|
20
|
+
return void res.status(400).json({ error: 'empty_body' });
|
|
21
|
+
try {
|
|
22
|
+
const out = writeNotePhoto(blob, hash, mime);
|
|
23
|
+
res.json({ success: true, hash: out.hash, dedup: out.dedup, size: out.size });
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
const msg = e.message;
|
|
27
|
+
const status = msg === 'photo_too_large' ? 413
|
|
28
|
+
: msg === 'photo_mime_not_allowed' ? 415
|
|
29
|
+
: msg === 'photo_hash_mismatch' ? 400
|
|
30
|
+
: 400;
|
|
31
|
+
res.status(status).json({ error: msg });
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
// 笔记图片下载 — 公开(笔记 landing page 公开可读,图也得公开)
|
|
35
|
+
app.get('/api/notes/photo/:hash', (req, res) => {
|
|
36
|
+
const hash = String(req.params.hash);
|
|
37
|
+
try {
|
|
38
|
+
const out = readNotePhoto(hash);
|
|
39
|
+
res.setHeader('Content-Type', out.mime);
|
|
40
|
+
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); // 内容寻址 → 永久缓存
|
|
41
|
+
res.setHeader('X-Content-Hash', hash);
|
|
42
|
+
res.send(out.blob);
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
const msg = e.message;
|
|
46
|
+
res.status(msg === 'photo_not_found' ? 404 : 400).json({ error: msg });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
// 创建 shareable — 双路径:笔记模式 / 外链或 native_text 模式
|
|
50
|
+
app.post('/api/shareables', (req, res) => {
|
|
51
|
+
const me = auth(req, res);
|
|
52
|
+
if (!me)
|
|
53
|
+
return;
|
|
54
|
+
const { external_url, title, description, native_text, related_product_id, related_anchor,
|
|
55
|
+
// Phase C 笔记新增字段:
|
|
56
|
+
kind, photo_hashes, related_order_id, parent_id, } = req.body || {};
|
|
57
|
+
const isNote = kind === 'note';
|
|
58
|
+
const trimUrl = (external_url || '').toString().trim();
|
|
59
|
+
const trimText = (native_text || '').toString().trim();
|
|
60
|
+
if (isNote) {
|
|
61
|
+
// ─── 笔记模式专属校验 ───────────────────────────────────────
|
|
62
|
+
if (!related_order_id)
|
|
63
|
+
return void res.json({ error: '笔记必须关联订单(你购买过的 completed 订单)' });
|
|
64
|
+
const order = db.prepare(`SELECT id, buyer_id, seller_id, product_id, status FROM orders WHERE id = ?`).get(related_order_id);
|
|
65
|
+
if (!order)
|
|
66
|
+
return void res.status(404).json({ error: '订单不存在' });
|
|
67
|
+
if (order.buyer_id !== me.id)
|
|
68
|
+
return void res.status(403).json({ error: '只能为自己买过的订单发笔记' });
|
|
69
|
+
if (order.status !== 'completed')
|
|
70
|
+
return void res.json({ error: '订单完成后才能发笔记' });
|
|
71
|
+
// 每订单 1 篇原创(转发不算 — 转发用 parent_id)
|
|
72
|
+
const dupOrder = db.prepare(`SELECT id FROM shareables WHERE owner_id = ? AND related_order_id = ? AND type = 'note' AND parent_id IS NULL AND status != 'removed' LIMIT 1`).get(me.id, related_order_id);
|
|
73
|
+
if (dupOrder && !parent_id)
|
|
74
|
+
return void res.json({ error: '该订单已发过原创笔记', existing_id: dupOrder.id });
|
|
75
|
+
if (trimText.length < 30)
|
|
76
|
+
return void res.json({ error: '笔记正文至少 30 字' });
|
|
77
|
+
if (trimText.length > 1000)
|
|
78
|
+
return void res.json({ error: '笔记正文不能超过 1000 字' });
|
|
79
|
+
if (!Array.isArray(photo_hashes) || photo_hashes.length === 0)
|
|
80
|
+
return void res.json({ error: '笔记必须至少 1 张图' });
|
|
81
|
+
if (photo_hashes.length > 9)
|
|
82
|
+
return void res.json({ error: '最多 9 张图' });
|
|
83
|
+
for (const h of photo_hashes) {
|
|
84
|
+
if (typeof h !== 'string' || !/^[0-9a-f]{64}$/.test(h))
|
|
85
|
+
return void res.json({ error: 'photo_hash 必须是 64 位 hex' });
|
|
86
|
+
if (!noteBlobExists(h))
|
|
87
|
+
return void res.json({ error: `图片 blob 未上传:${h.slice(0, 12)}…`, missing_hash: h });
|
|
88
|
+
}
|
|
89
|
+
// 图 hash 跨笔记唯一(防剽窃)— 审计修 C-1
|
|
90
|
+
const hashList = photo_hashes;
|
|
91
|
+
for (const h of hashList) {
|
|
92
|
+
const existing = db.prepare(`SELECT shareable_id FROM note_photo_index WHERE hash = ?`).get(h);
|
|
93
|
+
if (existing && existing.shareable_id) {
|
|
94
|
+
return void res.json({
|
|
95
|
+
error: `图片已被其它笔记使用(疑似剽窃):${h.slice(0, 12)}…`,
|
|
96
|
+
existing_note_id: existing.shareable_id,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const productId = order.product_id;
|
|
101
|
+
// parent_id 校验(转发链)
|
|
102
|
+
if (parent_id) {
|
|
103
|
+
const parent = db.prepare(`SELECT id, related_product_id FROM shareables WHERE id = ? AND status != 'removed'`).get(parent_id);
|
|
104
|
+
if (!parent)
|
|
105
|
+
return void res.json({ error: '原笔记不存在' });
|
|
106
|
+
if (parent.related_product_id !== productId)
|
|
107
|
+
return void res.json({ error: '转发必须基于同一商品的笔记' });
|
|
108
|
+
}
|
|
109
|
+
// 日上限
|
|
110
|
+
const todayCount = db.prepare(`SELECT COUNT(*) as n FROM shareables WHERE owner_id = ? AND created_at > datetime('now', '-1 day')`).get(me.id).n;
|
|
111
|
+
if (todayCount >= SHAREABLE_DAILY_LIMIT)
|
|
112
|
+
return void res.json({ error: `每日上限 ${SHAREABLE_DAILY_LIMIT} 条,请明天再来` });
|
|
113
|
+
const id = generateId('shr');
|
|
114
|
+
const ownerCode = db.prepare("SELECT permanent_code FROM users WHERE id = ?").get(me.id)?.permanent_code || null;
|
|
115
|
+
db.prepare(`INSERT INTO shareables
|
|
116
|
+
(id, owner_id, type, native_text, title, description, related_product_id, related_order_id, parent_id, photo_hashes, owner_code)
|
|
117
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?)`)
|
|
118
|
+
.run(id, me.id, 'note', trimText, (title || null), (description || null), productId, related_order_id, parent_id || null, JSON.stringify(hashList), ownerCode);
|
|
119
|
+
for (const h of hashList) {
|
|
120
|
+
try {
|
|
121
|
+
db.prepare(`INSERT OR IGNORE INTO note_photo_index (hash, shareable_id) VALUES (?,?)`).run(h, id);
|
|
122
|
+
}
|
|
123
|
+
catch { }
|
|
124
|
+
}
|
|
125
|
+
// 2026-05-22 audit P1:parseHashtags 写入 shareable_tags(话题系统)
|
|
126
|
+
const tags = parseHashtags((title || '') + ' ' + trimText);
|
|
127
|
+
for (const tg of tags) {
|
|
128
|
+
try {
|
|
129
|
+
db.prepare(`INSERT OR IGNORE INTO shareable_tags (shareable_id, tag) VALUES (?,?)`).run(id, tg);
|
|
130
|
+
}
|
|
131
|
+
catch { }
|
|
132
|
+
}
|
|
133
|
+
// 2026-05-22 audit P1:@ 提及 → notifications
|
|
134
|
+
const mentions = parseMentions((title || '') + ' ' + trimText);
|
|
135
|
+
notifyMentions(mentions, me.id, 'note', id, trimText.slice(0, 100));
|
|
136
|
+
flagNewAccountShareable(id, me.id);
|
|
137
|
+
refreshProductSharerCount(productId);
|
|
138
|
+
return void res.json({ ok: true, id, type: 'note', owner_code: ownerCode, photo_count: hashList.length, tags, mentions: mentions.map(m => m.handle) });
|
|
139
|
+
}
|
|
140
|
+
// ─── 既有外链 / native_text 路径 ─────────────────────────────
|
|
141
|
+
if (!trimUrl && !trimText)
|
|
142
|
+
return void res.json({ error: '请提供外链 URL 或文字内容' });
|
|
143
|
+
if (!related_product_id && !related_anchor)
|
|
144
|
+
return void res.json({ error: '请关联商品或流量口令(至少一项)' });
|
|
145
|
+
if (trimText.length > 2000)
|
|
146
|
+
return void res.json({ error: '文字内容不能超过 2000 字' });
|
|
147
|
+
if ((title || '').length > 100)
|
|
148
|
+
return void res.json({ error: '标题不能超过 100 字' });
|
|
149
|
+
if ((description || '').length > 200)
|
|
150
|
+
return void res.json({ error: '描述不能超过 200 字' });
|
|
151
|
+
const todayCount = db.prepare(`SELECT COUNT(*) as n FROM shareables WHERE owner_id = ? AND created_at > datetime('now', '-1 day')`).get(me.id).n;
|
|
152
|
+
if (todayCount >= SHAREABLE_DAILY_LIMIT)
|
|
153
|
+
return void res.json({ error: `每日上限 ${SHAREABLE_DAILY_LIMIT} 条,请明天再来` });
|
|
154
|
+
if (trimUrl) {
|
|
155
|
+
const dup = db.prepare(`SELECT id FROM shareables WHERE owner_id = ? AND external_url = ? AND status != 'removed' LIMIT 1`).get(me.id, trimUrl);
|
|
156
|
+
if (dup)
|
|
157
|
+
return void res.json({ error: '已存在相同链接,请编辑现有条目', existing_id: dup.id });
|
|
158
|
+
}
|
|
159
|
+
if (related_product_id) {
|
|
160
|
+
const p = db.prepare("SELECT id FROM products WHERE id = ?").get(related_product_id);
|
|
161
|
+
if (!p)
|
|
162
|
+
return void res.json({ error: '关联商品不存在' });
|
|
163
|
+
}
|
|
164
|
+
const id = generateId('shr');
|
|
165
|
+
const { type, platform, video_id, thumbnail } = trimUrl
|
|
166
|
+
? detectExternalPlatform(trimUrl)
|
|
167
|
+
: { type: 'native_text', platform: 'native', video_id: undefined, thumbnail: undefined };
|
|
168
|
+
const ownerCode = db.prepare("SELECT permanent_code FROM users WHERE id = ?").get(me.id)?.permanent_code || null;
|
|
169
|
+
db.prepare(`INSERT INTO shareables (id, owner_id, type, external_url, external_platform, external_video_id, thumbnail_url, title, description, native_text, related_product_id, related_anchor, owner_code)
|
|
170
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
|
171
|
+
.run(id, me.id, type, trimUrl || null, platform, video_id || null, thumbnail || null, (title || null), (description || null), trimText || null, related_product_id || null, related_anchor || null, ownerCode);
|
|
172
|
+
flagNewAccountShareable(id, me.id);
|
|
173
|
+
if (related_product_id)
|
|
174
|
+
refreshProductSharerCount(related_product_id);
|
|
175
|
+
res.json({ ok: true, id, type, platform, thumbnail, owner_code: ownerCode });
|
|
176
|
+
});
|
|
177
|
+
app.get('/api/shareables/me', (req, res) => {
|
|
178
|
+
const me = auth(req, res);
|
|
179
|
+
if (!me)
|
|
180
|
+
return;
|
|
181
|
+
const rows = db.prepare(`
|
|
182
|
+
SELECT s.*, p.title as product_title FROM shareables s
|
|
183
|
+
LEFT JOIN products p ON p.id = s.related_product_id
|
|
184
|
+
WHERE s.owner_id = ? AND s.status != 'removed'
|
|
185
|
+
ORDER BY s.created_at DESC LIMIT 100
|
|
186
|
+
`).all(me.id);
|
|
187
|
+
res.json({ shareables: rows });
|
|
188
|
+
});
|
|
189
|
+
// 里程碑 L3:创作者贡献仪表盘
|
|
190
|
+
app.get('/api/creator/stats', (req, res) => {
|
|
191
|
+
const me = auth(req, res);
|
|
192
|
+
if (!me)
|
|
193
|
+
return;
|
|
194
|
+
const meId = me.id;
|
|
195
|
+
const shareables = db.prepare(`
|
|
196
|
+
SELECT id, related_product_id, click_count, unique_click_count, flag_new_account, created_at
|
|
197
|
+
FROM shareables WHERE owner_id = ? AND status != 'removed'
|
|
198
|
+
`).all(meId);
|
|
199
|
+
const totalShares = shareables.length;
|
|
200
|
+
const productShares = shareables.filter(s => s.related_product_id);
|
|
201
|
+
const uniqueProducts = new Set(productShares.map(s => s.related_product_id)).size;
|
|
202
|
+
const rawClicks = shareables.reduce((a, s) => a + (s.click_count || 0), 0);
|
|
203
|
+
const uniqueClicks = shareables.reduce((a, s) => a + (s.unique_click_count || 0), 0);
|
|
204
|
+
const newAccountFlagged = shareables.filter(s => s.flag_new_account).length;
|
|
205
|
+
const conversions = db.prepare(`
|
|
206
|
+
SELECT COUNT(*) as n FROM product_share_attribution psa
|
|
207
|
+
JOIN orders o ON o.product_id = psa.product_id AND o.buyer_id = psa.recipient_id
|
|
208
|
+
WHERE psa.sharer_id = ? AND o.status = 'completed' AND o.created_at >= psa.created_at
|
|
209
|
+
`).get(meId).n;
|
|
210
|
+
const l1Earn = db.prepare(`
|
|
211
|
+
SELECT COALESCE(SUM(amount), 0) as total
|
|
212
|
+
FROM commission_records WHERE beneficiary_id = ? AND level = 1
|
|
213
|
+
`).get(meId).total;
|
|
214
|
+
// #7 按 source_type 分项 — 笔记 vs 普通分享 vs sponsor 链
|
|
215
|
+
const bySource = db.prepare(`
|
|
216
|
+
SELECT source_type, COALESCE(SUM(amount), 0) as total, COUNT(*) as cnt
|
|
217
|
+
FROM commission_records WHERE beneficiary_id = ?
|
|
218
|
+
GROUP BY source_type
|
|
219
|
+
`).all(meId);
|
|
220
|
+
const sourceBreakdown = { note: 0, link: 0, sponsor: 0 };
|
|
221
|
+
const sourceCntBreakdown = { note: 0, link: 0, sponsor: 0 };
|
|
222
|
+
for (const r of bySource) {
|
|
223
|
+
const k = (r.source_type === 'note' ? 'note' : r.source_type === 'link' ? 'link' : 'sponsor');
|
|
224
|
+
sourceBreakdown[k] += Number(r.total) || 0;
|
|
225
|
+
sourceCntBreakdown[k] += Number(r.cnt) || 0;
|
|
226
|
+
}
|
|
227
|
+
// 30 天点击趋势
|
|
228
|
+
const trend30d = db.prepare(`
|
|
229
|
+
SELECT substr(created_at, 1, 10) as day, COUNT(*) as raw_clicks, COUNT(DISTINCT ip_hash || ':' || ua_hash) as unique_clicks
|
|
230
|
+
FROM shareable_click_log
|
|
231
|
+
WHERE shareable_id IN (SELECT id FROM shareables WHERE owner_id = ?)
|
|
232
|
+
AND created_at > datetime('now', '-30 days')
|
|
233
|
+
GROUP BY day ORDER BY day ASC
|
|
234
|
+
`).all(meId);
|
|
235
|
+
res.json({
|
|
236
|
+
shares: { total: totalShares, product_count: uniqueProducts, new_account_flagged: newAccountFlagged },
|
|
237
|
+
clicks: { raw: rawClicks, unique: uniqueClicks, raw_to_unique_ratio: rawClicks > 0 ? Math.round(uniqueClicks / rawClicks * 100) / 100 : null },
|
|
238
|
+
conversions,
|
|
239
|
+
l1_commission_total: Math.round(l1Earn * 100) / 100,
|
|
240
|
+
commission_by_source: {
|
|
241
|
+
note: { total: Math.round(sourceBreakdown.note * 100) / 100, count: sourceCntBreakdown.note },
|
|
242
|
+
link: { total: Math.round(sourceBreakdown.link * 100) / 100, count: sourceCntBreakdown.link },
|
|
243
|
+
sponsor: { total: Math.round(sourceBreakdown.sponsor * 100) / 100, count: sourceCntBreakdown.sponsor },
|
|
244
|
+
},
|
|
245
|
+
trend_30d: trend30d,
|
|
246
|
+
ranking_contribution: {
|
|
247
|
+
description: '你作为独立分享者,对相关商品的 ranking 信号有贡献(unique_sharer_count × 2 / 商品)',
|
|
248
|
+
products_boosted: uniqueProducts,
|
|
249
|
+
ranking_signal_value: uniqueProducts * 2,
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
// 策展引用:按 click*1 + like*3 + induced_orders*10 加权排序,取 top 10
|
|
254
|
+
app.get('/api/shareables/by-product/:pid', (req, res) => {
|
|
255
|
+
const user = auth(req, res);
|
|
256
|
+
if (!user)
|
|
257
|
+
return;
|
|
258
|
+
const rows = db.prepare(`
|
|
259
|
+
SELECT * FROM (
|
|
260
|
+
SELECT s.*, u.name as owner_name, u.handle as owner_handle,
|
|
261
|
+
(SELECT COUNT(DISTINCT o.id) FROM orders o
|
|
262
|
+
JOIN product_share_attribution psa
|
|
263
|
+
ON psa.recipient_id = o.buyer_id AND psa.product_id = o.product_id
|
|
264
|
+
WHERE psa.shareable_id = s.id AND o.status = 'completed') as induced_orders
|
|
265
|
+
FROM shareables s
|
|
266
|
+
LEFT JOIN users u ON u.id = s.owner_id
|
|
267
|
+
WHERE s.related_product_id = ? AND s.status = 'active'
|
|
268
|
+
) sub
|
|
269
|
+
ORDER BY (click_count * 1.0 + like_count * 3.0 + induced_orders * 10.0) DESC, created_at DESC
|
|
270
|
+
LIMIT 10
|
|
271
|
+
`).all(req.params.pid);
|
|
272
|
+
for (const r of rows) {
|
|
273
|
+
r.badges = noteAuthenticityBadges(r);
|
|
274
|
+
}
|
|
275
|
+
res.json({ shareables: rows });
|
|
276
|
+
});
|
|
277
|
+
app.get('/api/shareables/by-anchor/:anchor', (req, res) => {
|
|
278
|
+
const user = auth(req, res);
|
|
279
|
+
if (!user)
|
|
280
|
+
return;
|
|
281
|
+
const rows = db.prepare(`
|
|
282
|
+
SELECT s.*, u.name as owner_name FROM shareables s
|
|
283
|
+
LEFT JOIN users u ON u.id = s.owner_id
|
|
284
|
+
WHERE s.related_anchor = ? AND s.status = 'active'
|
|
285
|
+
ORDER BY s.created_at DESC LIMIT 50
|
|
286
|
+
`).all(req.params.anchor);
|
|
287
|
+
res.json({ shareables: rows });
|
|
288
|
+
});
|
|
289
|
+
// Phase D2 笔记 list — 公开 feed,3 种 sort
|
|
290
|
+
// sort=newest: created_at DESC
|
|
291
|
+
// sort=trending: (likes*2 + click/10 + freshness/(age_hours+1)) DESC
|
|
292
|
+
// sort=following: 需登录,仅显示 follows.followee_id 的笔记
|
|
293
|
+
app.get('/api/notes', (req, res) => {
|
|
294
|
+
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 20));
|
|
295
|
+
const cursor = req.query.cursor ? String(req.query.cursor) : null;
|
|
296
|
+
const sort = String(req.query.sort || 'newest');
|
|
297
|
+
const user = getUser(req); // 不强制 auth;following 模式需要
|
|
298
|
+
let where = `s.type = 'note' AND s.status = 'active'`;
|
|
299
|
+
const args = [];
|
|
300
|
+
if (cursor) {
|
|
301
|
+
where += ` AND s.created_at < ?`;
|
|
302
|
+
args.push(cursor);
|
|
303
|
+
}
|
|
304
|
+
let orderBy = `s.created_at DESC`;
|
|
305
|
+
if (sort === 'trending') {
|
|
306
|
+
orderBy = `(COALESCE(s.like_count,0)*2 + COALESCE(s.click_count,0)/10.0 - (julianday('now') - julianday(s.created_at))*0.5) DESC, s.created_at DESC`;
|
|
307
|
+
}
|
|
308
|
+
else if (sort === 'following') {
|
|
309
|
+
if (!user)
|
|
310
|
+
return void res.status(401).json({ error: 'auth_required_for_following' });
|
|
311
|
+
where += ` AND s.owner_id IN (SELECT followee_id FROM follows WHERE follower_id = ?)`;
|
|
312
|
+
args.push(user.id);
|
|
313
|
+
}
|
|
314
|
+
const sql = `
|
|
315
|
+
SELECT s.id, s.owner_id, s.owner_code, s.title, s.native_text, s.photo_hashes,
|
|
316
|
+
s.related_order_id,
|
|
317
|
+
s.click_count, s.like_count, s.created_at,
|
|
318
|
+
s.related_product_id, s.parent_id,
|
|
319
|
+
p.title as product_title, p.price as product_price,
|
|
320
|
+
u.handle as owner_handle, u.name as owner_name, u.region as owner_region
|
|
321
|
+
FROM shareables s
|
|
322
|
+
LEFT JOIN users u ON u.id = s.owner_id
|
|
323
|
+
LEFT JOIN products p ON p.id = s.related_product_id
|
|
324
|
+
WHERE ${where}
|
|
325
|
+
ORDER BY ${orderBy}
|
|
326
|
+
LIMIT ?
|
|
327
|
+
`;
|
|
328
|
+
args.push(limit + 1);
|
|
329
|
+
const rows = db.prepare(sql).all(...args);
|
|
330
|
+
const hasMore = rows.length > limit;
|
|
331
|
+
const items = rows.slice(0, limit).map(r => {
|
|
332
|
+
let photos = [];
|
|
333
|
+
try {
|
|
334
|
+
photos = JSON.parse(r.photo_hashes || '[]');
|
|
335
|
+
}
|
|
336
|
+
catch { }
|
|
337
|
+
const badges = noteAuthenticityBadges(r);
|
|
338
|
+
return {
|
|
339
|
+
id: r.id, owner_handle: r.owner_handle, owner_name: r.owner_name, owner_region: r.owner_region, owner_code: r.owner_code,
|
|
340
|
+
title: r.title, body_excerpt: (r.native_text || '').slice(0, 120),
|
|
341
|
+
first_photo: photos[0] || null, photo_count: photos.length,
|
|
342
|
+
product: r.related_product_id ? { id: r.related_product_id, title: r.product_title, price: r.product_price } : null,
|
|
343
|
+
stats: { clicks: r.click_count, likes: r.like_count },
|
|
344
|
+
is_repost: !!r.parent_id,
|
|
345
|
+
created_at: r.created_at,
|
|
346
|
+
badges,
|
|
347
|
+
};
|
|
348
|
+
});
|
|
349
|
+
// 审计修 D-4:trending 不用 cursor(分数排序 cursor 不可靠)
|
|
350
|
+
const nextCursor = (hasMore && sort !== 'trending') ? items[items.length - 1].created_at : null;
|
|
351
|
+
res.json({ items, next_cursor: nextCursor, sort });
|
|
352
|
+
});
|
|
353
|
+
// Phase C 笔记公开读 — 任何人可读
|
|
354
|
+
app.get('/api/shareables/:id', (req, res) => {
|
|
355
|
+
const id = String(req.params.id);
|
|
356
|
+
const row = db.prepare(`
|
|
357
|
+
SELECT s.id, s.owner_id, s.owner_code, s.type, s.title, s.description, s.native_text,
|
|
358
|
+
s.related_product_id, s.related_order_id, s.parent_id, s.photo_hashes,
|
|
359
|
+
s.click_count, s.unique_click_count, s.like_count, s.created_at, s.status,
|
|
360
|
+
u.handle as owner_handle, u.name as owner_name, u.region as owner_region
|
|
361
|
+
FROM shareables s LEFT JOIN users u ON u.id = s.owner_id
|
|
362
|
+
WHERE s.id = ? AND s.status = 'active'
|
|
363
|
+
`).get(id);
|
|
364
|
+
if (!row)
|
|
365
|
+
return void res.status(404).json({ error: 'not_found' });
|
|
366
|
+
let photos = [];
|
|
367
|
+
try {
|
|
368
|
+
photos = JSON.parse(row.photo_hashes || '[]');
|
|
369
|
+
}
|
|
370
|
+
catch { }
|
|
371
|
+
let product = null;
|
|
372
|
+
if (row.related_product_id) {
|
|
373
|
+
product = db.prepare(`SELECT id, title, price, category, images FROM products WHERE id = ?`).get(row.related_product_id);
|
|
374
|
+
}
|
|
375
|
+
const tags = db.prepare(`SELECT tag FROM shareable_tags WHERE shareable_id = ? ORDER BY id`).all(id).map(r => r.tag);
|
|
376
|
+
const badges = noteAuthenticityBadges(row);
|
|
377
|
+
res.json({
|
|
378
|
+
id: row.id, type: row.type,
|
|
379
|
+
title: row.title, description: row.description, body: row.native_text,
|
|
380
|
+
photo_hashes: photos,
|
|
381
|
+
parent_id: row.parent_id,
|
|
382
|
+
owner_id: row.owner_id,
|
|
383
|
+
owner_code: row.owner_code,
|
|
384
|
+
owner: { id: row.owner_id, handle: row.owner_handle, name: row.owner_name, region: row.owner_region, code: row.owner_code },
|
|
385
|
+
product,
|
|
386
|
+
tags,
|
|
387
|
+
stats: { clicks: row.click_count, unique_clicks: row.unique_click_count, likes: row.like_count },
|
|
388
|
+
created_at: row.created_at,
|
|
389
|
+
badges,
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
app.patch('/api/shareables/:id', (req, res) => {
|
|
393
|
+
const me = auth(req, res);
|
|
394
|
+
if (!me)
|
|
395
|
+
return;
|
|
396
|
+
const row = db.prepare("SELECT owner_id FROM shareables WHERE id = ?").get(req.params.id);
|
|
397
|
+
if (!row || row.owner_id !== me.id)
|
|
398
|
+
return void res.json({ error: '无权操作' });
|
|
399
|
+
const updates = [];
|
|
400
|
+
const values = [];
|
|
401
|
+
// 2026-05-22:扩展支持 native_text(笔记正文)编辑 + 长度校验
|
|
402
|
+
for (const k of ['title', 'description', 'native_text', 'related_product_id', 'related_anchor']) {
|
|
403
|
+
if (k in (req.body || {})) {
|
|
404
|
+
let v = req.body[k];
|
|
405
|
+
if (v != null && typeof v === 'string') {
|
|
406
|
+
if (k === 'title')
|
|
407
|
+
v = v.slice(0, 100);
|
|
408
|
+
if (k === 'description')
|
|
409
|
+
v = v.slice(0, 200);
|
|
410
|
+
if (k === 'native_text') {
|
|
411
|
+
v = v.trim();
|
|
412
|
+
if (v.length > 0 && (v.length < 30 || v.length > 1000)) {
|
|
413
|
+
return void res.json({ error: '正文长度需 30-1000 字' });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
updates.push(`${k} = ?`);
|
|
418
|
+
values.push(v || null);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if ((req.body || {}).status === 'archived' || (req.body || {}).status === 'active') {
|
|
422
|
+
updates.push('status = ?');
|
|
423
|
+
values.push(req.body.status);
|
|
424
|
+
}
|
|
425
|
+
if (updates.length === 0)
|
|
426
|
+
return void res.json({ error: '没有可更新字段' });
|
|
427
|
+
updates.push(`updated_at = datetime('now')`);
|
|
428
|
+
values.push(req.params.id);
|
|
429
|
+
db.prepare(`UPDATE shareables SET ${updates.join(', ')} WHERE id = ?`).run(...values);
|
|
430
|
+
res.json({ ok: true });
|
|
431
|
+
});
|
|
432
|
+
app.delete('/api/shareables/:id', (req, res) => {
|
|
433
|
+
const me = auth(req, res);
|
|
434
|
+
if (!me)
|
|
435
|
+
return;
|
|
436
|
+
const row = db.prepare("SELECT owner_id, related_product_id, like_count, status, type FROM shareables WHERE id = ?").get(req.params.id);
|
|
437
|
+
if (!row || row.owner_id !== me.id)
|
|
438
|
+
return void res.json({ error: '无权操作' });
|
|
439
|
+
if (row.status === 'removed')
|
|
440
|
+
return void res.json({ ok: true }); // 幂等
|
|
441
|
+
db.transaction(() => {
|
|
442
|
+
db.prepare(`UPDATE shareables SET status = 'removed', updated_at = datetime('now') WHERE id = ?`).run(req.params.id);
|
|
443
|
+
// E1 anchor GC:把所有指向该 shareable 的 active anchor 设为 retired
|
|
444
|
+
try {
|
|
445
|
+
retireAnchorsByTarget(db, 'shareable', String(req.params.id));
|
|
446
|
+
}
|
|
447
|
+
catch (e) {
|
|
448
|
+
console.warn('[anchor-gc shareable]', e.message);
|
|
449
|
+
}
|
|
450
|
+
// P1 fix #2: 同步 products.total_likes(扣掉这个 shareable 的累计赞)
|
|
451
|
+
if (row.related_product_id && row.like_count > 0) {
|
|
452
|
+
db.prepare('UPDATE products SET total_likes = MAX(0, total_likes - ?) WHERE id = ?').run(row.like_count, row.related_product_id);
|
|
453
|
+
}
|
|
454
|
+
// 审计修 C-4:笔记删除时清理 photo index(释放 hash 让别人可以重用)
|
|
455
|
+
if (row.type === 'note') {
|
|
456
|
+
db.prepare(`DELETE FROM note_photo_index WHERE shareable_id = ?`).run(req.params.id);
|
|
457
|
+
}
|
|
458
|
+
})();
|
|
459
|
+
// P1 fix #3: 重算 unique_sharer_count
|
|
460
|
+
if (row.related_product_id) {
|
|
461
|
+
try {
|
|
462
|
+
refreshProductSharerCount(row.related_product_id);
|
|
463
|
+
}
|
|
464
|
+
catch (e) {
|
|
465
|
+
console.error('[LIKE-audit refresh sharer]', e);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
res.json({ ok: true });
|
|
469
|
+
});
|
|
470
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export function registerShopsRoutes(app, deps) {
|
|
2
|
+
const { db, auth } = deps;
|
|
3
|
+
app.get('/api/shops/:identifier', (req, res) => {
|
|
4
|
+
const id = String(req.params.identifier || '').replace(/^@/, '');
|
|
5
|
+
// 先按 handle 查,找不到再按 id
|
|
6
|
+
let seller = db.prepare(`
|
|
7
|
+
SELECT id, name, handle, role, bio, shop_banner_url, shop_intro, created_at, region
|
|
8
|
+
FROM users WHERE handle = ? AND role = 'seller'
|
|
9
|
+
`).get(id);
|
|
10
|
+
if (!seller) {
|
|
11
|
+
seller = db.prepare(`
|
|
12
|
+
SELECT id, name, handle, role, bio, shop_banner_url, shop_intro, created_at, region
|
|
13
|
+
FROM users WHERE id = ? AND role = 'seller'
|
|
14
|
+
`).get(id);
|
|
15
|
+
}
|
|
16
|
+
if (!seller)
|
|
17
|
+
return void res.status(404).json({ error: '店铺不存在' });
|
|
18
|
+
const sellerId = String(seller.id);
|
|
19
|
+
const products = db.prepare(`
|
|
20
|
+
SELECT p.id, p.title, p.price, p.stock, p.category, p.images, p.has_variants, p.commission_rate,
|
|
21
|
+
(SELECT COUNT(1) FROM orders o WHERE o.product_id = p.id AND o.status = 'completed') as sales_count
|
|
22
|
+
FROM products p
|
|
23
|
+
WHERE p.seller_id = ? AND p.status = 'active'
|
|
24
|
+
ORDER BY sales_count DESC, p.created_at DESC
|
|
25
|
+
LIMIT 50
|
|
26
|
+
`).all(sellerId);
|
|
27
|
+
const ratingsAgg = db.prepare(`
|
|
28
|
+
SELECT COUNT(*) as cnt, COALESCE(AVG(stars), 0) as avg_stars FROM order_ratings WHERE seller_id = ?
|
|
29
|
+
`).get(sellerId);
|
|
30
|
+
const followers = db.prepare(`SELECT COUNT(*) as n FROM follows WHERE followee_id = ?`).get(sellerId).n;
|
|
31
|
+
const completedOrders = db.prepare(`SELECT COUNT(*) as n FROM orders WHERE seller_id = ? AND status = 'completed'`).get(sellerId).n;
|
|
32
|
+
// 当前 viewer 是否关注
|
|
33
|
+
let is_following = false;
|
|
34
|
+
try {
|
|
35
|
+
const token = (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
|
|
36
|
+
if (token) {
|
|
37
|
+
const u = db.prepare('SELECT id FROM users WHERE api_key = ?').get(token);
|
|
38
|
+
if (u)
|
|
39
|
+
is_following = !!db.prepare('SELECT 1 FROM follows WHERE follower_id = ? AND followee_id = ?').get(u.id, sellerId);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch { }
|
|
43
|
+
const recentRatings = db.prepare(`
|
|
44
|
+
SELECT r.stars, r.comment, r.reply, r.created_at,
|
|
45
|
+
u.handle as buyer_handle, p.title as product_title
|
|
46
|
+
FROM order_ratings r
|
|
47
|
+
JOIN users u ON u.id = r.buyer_id
|
|
48
|
+
JOIN products p ON p.id = r.product_id
|
|
49
|
+
WHERE r.seller_id = ?
|
|
50
|
+
ORDER BY r.created_at DESC LIMIT 5
|
|
51
|
+
`).all(sellerId);
|
|
52
|
+
res.json({
|
|
53
|
+
seller,
|
|
54
|
+
stats: {
|
|
55
|
+
products: products.length,
|
|
56
|
+
followers,
|
|
57
|
+
completed_orders: completedOrders,
|
|
58
|
+
rating_avg: ratingsAgg.cnt > 0 ? Number(ratingsAgg.avg_stars) : null,
|
|
59
|
+
rating_count: ratingsAgg.cnt,
|
|
60
|
+
},
|
|
61
|
+
products,
|
|
62
|
+
recent_ratings: recentRatings,
|
|
63
|
+
is_following,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
// 卖家更新自己店铺装饰
|
|
67
|
+
app.patch('/api/shops/me', (req, res) => {
|
|
68
|
+
const user = auth(req, res);
|
|
69
|
+
if (!user)
|
|
70
|
+
return;
|
|
71
|
+
if (user.role !== 'seller')
|
|
72
|
+
return void res.status(403).json({ error: '仅卖家可设置' });
|
|
73
|
+
const { shop_intro, shop_banner_url, bio } = req.body || {};
|
|
74
|
+
const sets = [];
|
|
75
|
+
const args = [];
|
|
76
|
+
if (shop_intro !== undefined) {
|
|
77
|
+
sets.push('shop_intro = ?');
|
|
78
|
+
args.push(shop_intro ? String(shop_intro).slice(0, 2000) : null);
|
|
79
|
+
}
|
|
80
|
+
if (shop_banner_url !== undefined) {
|
|
81
|
+
if (shop_banner_url && !/^https?:\/\//.test(String(shop_banner_url))) {
|
|
82
|
+
return void res.status(400).json({ error: 'banner URL 必须是 http(s)://' });
|
|
83
|
+
}
|
|
84
|
+
sets.push('shop_banner_url = ?');
|
|
85
|
+
args.push(shop_banner_url ? String(shop_banner_url).slice(0, 500) : null);
|
|
86
|
+
}
|
|
87
|
+
if (bio !== undefined) {
|
|
88
|
+
sets.push('bio = ?');
|
|
89
|
+
args.push(bio ? String(bio).slice(0, 200) : null);
|
|
90
|
+
}
|
|
91
|
+
if (sets.length === 0)
|
|
92
|
+
return void res.status(400).json({ error: '无可更新字段' });
|
|
93
|
+
sets.push(`updated_at = datetime('now')`);
|
|
94
|
+
args.push(user.id);
|
|
95
|
+
db.prepare(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...args);
|
|
96
|
+
res.json({ success: true });
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export function registerSignalingRoutes(app, deps) {
|
|
2
|
+
const { db, auth, generateId } = deps;
|
|
3
|
+
app.post('/api/signaling/send', (req, res) => {
|
|
4
|
+
const me = auth(req, res);
|
|
5
|
+
if (!me)
|
|
6
|
+
return;
|
|
7
|
+
const { to, type, data } = req.body || {};
|
|
8
|
+
if (!to || !type || !data)
|
|
9
|
+
return void res.json({ error: '缺少参数' });
|
|
10
|
+
if (!['offer', 'answer', 'ice'].includes(type))
|
|
11
|
+
return void res.json({ error: 'type 不合法' });
|
|
12
|
+
if (JSON.stringify(data).length > 50000)
|
|
13
|
+
return void res.json({ error: 'data 过大' });
|
|
14
|
+
db.prepare(`INSERT INTO signaling_queue (id, to_peer_id, from_peer_id, signal_type, signal_data, created_at)
|
|
15
|
+
VALUES (?,?,?,?,?,datetime('now'))`)
|
|
16
|
+
.run(generateId('sig'), to, me.id, type, JSON.stringify(data));
|
|
17
|
+
res.json({ ok: true });
|
|
18
|
+
});
|
|
19
|
+
app.get('/api/signaling/poll', (req, res) => {
|
|
20
|
+
const me = auth(req, res);
|
|
21
|
+
if (!me)
|
|
22
|
+
return;
|
|
23
|
+
const rows = db.prepare(`
|
|
24
|
+
SELECT id, from_peer_id, signal_type, signal_data, created_at FROM signaling_queue
|
|
25
|
+
WHERE to_peer_id = ? AND delivered_at IS NULL AND created_at > datetime('now', '-2 minutes')
|
|
26
|
+
ORDER BY created_at ASC LIMIT 50
|
|
27
|
+
`).all(me.id);
|
|
28
|
+
if (rows.length > 0) {
|
|
29
|
+
const ids = rows.map(r => r.id);
|
|
30
|
+
db.prepare(`UPDATE signaling_queue SET delivered_at = datetime('now') WHERE id IN (${ids.map(() => '?').join(',')})`).run(...ids);
|
|
31
|
+
}
|
|
32
|
+
res.json({ signals: rows.map(r => {
|
|
33
|
+
let signal_data = null;
|
|
34
|
+
try {
|
|
35
|
+
signal_data = JSON.parse(r.signal_data);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
signal_data = r.signal_data;
|
|
39
|
+
}
|
|
40
|
+
return { ...r, signal_data };
|
|
41
|
+
}) });
|
|
42
|
+
});
|
|
43
|
+
}
|