@seasonkoh/webaz 0.1.23 → 0.1.25
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/README.md +2 -0
- package/dist/layer0-foundation/L0-1-database/db-backends/pg-backend.js +51 -0
- package/dist/layer0-foundation/L0-1-database/db-backends/sql-dialect-datetime.js +437 -0
- package/dist/layer0-foundation/L0-1-database/db-backends/sql-placeholders.js +98 -0
- package/dist/layer0-foundation/L0-1-database/db.js +65 -0
- package/dist/layer0-foundation/L0-2-state-machine/order-chain.js +13 -11
- package/dist/layer0-foundation/L0-2-state-machine/transitions.js +1 -1
- package/dist/layer0-foundation/L0-5-manifest/manifest.js +13 -11
- package/dist/layer1-agent/L1-1-mcp-server/server.js +198 -83
- package/dist/layer1-agent/L1-2-external-anchor/anchor-engine.js +14 -12
- package/dist/layer2-business/L2-6-notifications/notification-engine.js +8 -5
- package/dist/layer2-business/L2-7-snf/snf-engine.js +16 -14
- package/dist/layer2-business/L2-8-feedback/build-feedback-engine.js +18 -10
- package/dist/layer2-business/L2-9-contribution/build-reputation-engine.js +37 -23
- package/dist/layer2-business/L2-9-contribution/build-task-agent-metadata-store.js +173 -0
- package/dist/layer2-business/L2-9-contribution/build-task-participation.js +47 -0
- package/dist/layer2-business/L2-9-contribution/build-task-read.js +222 -0
- package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +10 -2
- package/dist/layer2-business/L2-9-contribution/canonical-contribution-target.js +16 -0
- package/dist/layer2-business/L2-9-contribution/contribution-display-envelope.js +40 -0
- package/dist/layer2-business/L2-9-contribution/contribution-score-contract.js +36 -0
- package/dist/layer2-business/L2-9-contribution/contribution-score-evidence.js +61 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/canonical.js +60 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/github-credential.schema.js +140 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/github-fetch-adapter.js +437 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/self-consistency.js +38 -0
- package/dist/layer2-business/L2-9-contribution/github-credential/verifier.js +231 -0
- package/dist/layer2-business/L2-9-contribution/github-credential-ingestion-engine.js +145 -0
- package/dist/layer2-business/L2-9-contribution/github-credential-store.js +115 -0
- package/dist/layer2-business/L2-9-contribution/identity-binding-engine.js +134 -0
- package/dist/layer2-business/L2-9-contribution/identity-binding-store.js +101 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-challenge-engine.js +126 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-challenge-store.js +30 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-engine.js +109 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-fact-precondition.js +22 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-proof-verifier.js +97 -0
- package/dist/layer2-business/L2-9-contribution/identity-claim-read.js +59 -0
- package/dist/layer2-business/L2-9-contribution/task-proposal-store.js +129 -0
- package/dist/layer2-business/L2-notes/note-photo-storage.js +4 -2
- package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +17 -15
- package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +11 -8
- package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +9 -8
- package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +11 -8
- package/dist/layer4-economics/L4-4-skill-market/skill-listing-engine.js +22 -16
- package/dist/pwa/acp-feed.js +13 -1
- package/dist/pwa/contract-fingerprint.js +2 -0
- package/dist/pwa/endpoint-actions.js +5 -1
- package/dist/pwa/goal-index.js +8 -8
- package/dist/pwa/human-presence.js +62 -0
- package/dist/pwa/public/app.js +575 -68
- package/dist/pwa/public/i18n.js +29 -20
- package/dist/pwa/public/index.html +1 -0
- package/dist/pwa/public/openapi.json +2 -2
- package/dist/pwa/rate-limit.js +22 -0
- package/dist/pwa/routes/account-deletion.js +15 -13
- package/dist/pwa/routes/addresses.js +10 -9
- package/dist/pwa/routes/admin-admins.js +13 -14
- package/dist/pwa/routes/admin-analytics.js +109 -69
- package/dist/pwa/routes/admin-catalog.js +13 -11
- package/dist/pwa/routes/admin-editor-picks.js +15 -10
- package/dist/pwa/routes/admin-events.js +5 -3
- package/dist/pwa/routes/admin-health.js +2 -1
- package/dist/pwa/routes/admin-moderation.js +26 -29
- package/dist/pwa/routes/admin-ops.js +22 -21
- package/dist/pwa/routes/admin-protocol-params.js +16 -19
- package/dist/pwa/routes/admin-reports.js +23 -21
- package/dist/pwa/routes/admin-tokenomics.js +26 -25
- package/dist/pwa/routes/admin-users-lifecycle.js +37 -40
- package/dist/pwa/routes/admin-users-query.js +54 -53
- package/dist/pwa/routes/admin-verifier-flow.js +82 -41
- package/dist/pwa/routes/admin-verifier-whitelist.js +55 -27
- package/dist/pwa/routes/admin-wallet-ops.js +7 -5
- package/dist/pwa/routes/agent-buy.js +46 -22
- package/dist/pwa/routes/agent-governance.js +52 -56
- package/dist/pwa/routes/ai.js +7 -5
- package/dist/pwa/routes/analytics.js +43 -41
- package/dist/pwa/routes/anchors.js +19 -20
- package/dist/pwa/routes/announcements.js +13 -13
- package/dist/pwa/routes/arbitrator.js +97 -31
- package/dist/pwa/routes/auction.js +153 -114
- package/dist/pwa/routes/auth-login.js +6 -4
- package/dist/pwa/routes/auth-read.js +11 -9
- package/dist/pwa/routes/auth-register.js +35 -20
- package/dist/pwa/routes/auth-sessions.js +12 -11
- package/dist/pwa/routes/blocklist.js +16 -15
- package/dist/pwa/routes/build-feedback.js +10 -9
- package/dist/pwa/routes/build-reputation.js +6 -2
- package/dist/pwa/routes/build-tasks.js +45 -13
- package/dist/pwa/routes/buyer-feeds.js +27 -25
- package/dist/pwa/routes/cart.js +16 -15
- package/dist/pwa/routes/charity.js +212 -150
- package/dist/pwa/routes/chat.js +42 -43
- package/dist/pwa/routes/checkin-tasks.js +10 -9
- package/dist/pwa/routes/checkout-helpers.js +12 -10
- package/dist/pwa/routes/claim-initiators.js +34 -14
- package/dist/pwa/routes/claim-verify.js +86 -53
- package/dist/pwa/routes/claim-voting.js +43 -18
- package/dist/pwa/routes/contribution-identity.js +147 -0
- package/dist/pwa/routes/contribution-score.js +19 -0
- package/dist/pwa/routes/coupons.js +19 -16
- package/dist/pwa/routes/dashboards.js +18 -16
- package/dist/pwa/routes/dispute-cases.js +25 -24
- package/dist/pwa/routes/disputes-read.js +45 -51
- package/dist/pwa/routes/disputes-write.js +124 -61
- package/dist/pwa/routes/evidence.js +9 -9
- package/dist/pwa/routes/external-anchors.js +13 -12
- package/dist/pwa/routes/feedback.js +29 -33
- package/dist/pwa/routes/flash-sales.js +18 -16
- package/dist/pwa/routes/follows.js +25 -24
- package/dist/pwa/routes/governance-auto-deactivate.js +21 -9
- package/dist/pwa/routes/governance-onboarding.js +70 -59
- package/dist/pwa/routes/group-buys.js +22 -22
- package/dist/pwa/routes/growth.js +33 -30
- package/dist/pwa/routes/import-product.js +12 -10
- package/dist/pwa/routes/kyc.js +9 -8
- package/dist/pwa/routes/leaderboard.js +20 -18
- package/dist/pwa/routes/listings.js +23 -22
- package/dist/pwa/routes/logistics.js +10 -8
- package/dist/pwa/routes/manifests.js +27 -27
- package/dist/pwa/routes/me-data.js +23 -21
- package/dist/pwa/routes/notifications.js +7 -6
- package/dist/pwa/routes/offers.js +30 -12
- package/dist/pwa/routes/orders-action.js +33 -17
- package/dist/pwa/routes/orders-create.js +75 -20
- package/dist/pwa/routes/orders-read.js +21 -20
- package/dist/pwa/routes/p2p-products.js +30 -18
- package/dist/pwa/routes/payments-governance.js +61 -56
- package/dist/pwa/routes/peers.js +9 -8
- package/dist/pwa/routes/pin-receipts.js +13 -13
- package/dist/pwa/routes/products-aliases.js +12 -10
- package/dist/pwa/routes/products-claims.js +36 -17
- package/dist/pwa/routes/products-create.js +53 -38
- package/dist/pwa/routes/products-crud.js +17 -16
- package/dist/pwa/routes/products-links.js +49 -26
- package/dist/pwa/routes/products-list.js +6 -4
- package/dist/pwa/routes/products-meta.js +40 -39
- package/dist/pwa/routes/products-update.js +19 -5
- package/dist/pwa/routes/profile-credentials.js +14 -16
- package/dist/pwa/routes/profile-identity.js +14 -13
- package/dist/pwa/routes/profile-location.js +7 -6
- package/dist/pwa/routes/profile-placement.js +19 -17
- package/dist/pwa/routes/profile-prefs.js +11 -11
- package/dist/pwa/routes/promoter.js +55 -49
- package/dist/pwa/routes/public-build-tasks.js +19 -0
- package/dist/pwa/routes/public-utils.js +108 -46
- package/dist/pwa/routes/push.js +16 -15
- package/dist/pwa/routes/ratings.js +30 -30
- package/dist/pwa/routes/recover-key.js +13 -12
- package/dist/pwa/routes/referral.js +37 -32
- package/dist/pwa/routes/reputation.js +3 -2
- package/dist/pwa/routes/returns.js +76 -73
- package/dist/pwa/routes/reviews.js +41 -18
- package/dist/pwa/routes/rewards-apply.js +16 -15
- package/dist/pwa/routes/rewards-auto-downgrade.js +9 -7
- package/dist/pwa/routes/rewards-escrow-expire.js +7 -5
- package/dist/pwa/routes/rfqs.js +163 -85
- package/dist/pwa/routes/search.js +16 -14
- package/dist/pwa/routes/secondhand.js +25 -22
- package/dist/pwa/routes/seller-quota.js +24 -26
- package/dist/pwa/routes/share-redirects.js +59 -55
- package/dist/pwa/routes/shareables-interactions.js +34 -35
- package/dist/pwa/routes/shareables.js +55 -51
- package/dist/pwa/routes/shop-referral.js +57 -0
- package/dist/pwa/routes/shops.js +20 -18
- package/dist/pwa/routes/signaling.js +10 -9
- package/dist/pwa/routes/skill-market.js +16 -16
- package/dist/pwa/routes/skills.js +15 -14
- package/dist/pwa/routes/snf.js +14 -13
- package/dist/pwa/routes/tags.js +10 -9
- package/dist/pwa/routes/task-proposals.js +45 -0
- package/dist/pwa/routes/trial.js +69 -51
- package/dist/pwa/routes/trusted-kpi.js +20 -18
- package/dist/pwa/routes/url-claim.js +67 -28
- package/dist/pwa/routes/users-public.js +62 -60
- package/dist/pwa/routes/variants.js +12 -13
- package/dist/pwa/routes/verifier-user.js +61 -21
- package/dist/pwa/routes/verify-tasks.js +49 -25
- package/dist/pwa/routes/waitlist.js +16 -15
- package/dist/pwa/routes/wallet-read.js +74 -36
- package/dist/pwa/routes/wallet-write.js +12 -9
- package/dist/pwa/routes/webauthn.js +25 -26
- package/dist/pwa/routes/webhooks.js +26 -26
- package/dist/pwa/routes/welcome.js +45 -50
- package/dist/pwa/routes/wishlist-qa.js +29 -32
- package/dist/pwa/server.js +237 -81
- package/dist/version.js +1 -1
- package/package.json +47 -2
|
@@ -1,18 +1,19 @@
|
|
|
1
|
+
import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
1
2
|
export function registerShareablesInteractionsRoutes(app, deps) {
|
|
2
3
|
const { db, auth, generateId, rateLimitOk, piiSanitize, detectFraud, commentBlocklistHit, llmModerateComment, parseMentions, notifyMentions } = deps;
|
|
3
|
-
app.post('/api/shareables/:id/click', (req, res) => {
|
|
4
|
+
app.post('/api/shareables/:id/click', async (req, res) => {
|
|
4
5
|
// 点击计数(不要求 auth — 任何人点击外链都计数)
|
|
5
|
-
|
|
6
|
+
await dbRun("UPDATE shareables SET click_count = click_count + 1 WHERE id = ? AND status = 'active'", [req.params.id]);
|
|
6
7
|
res.json({ ok: true });
|
|
7
8
|
});
|
|
8
9
|
// LIKE 系统:toggle 点赞(每用户对每 shareable 一票;不能给自己点)
|
|
9
|
-
app.post('/api/shareables/:id/like', (req, res) => {
|
|
10
|
+
app.post('/api/shareables/:id/like', async (req, res) => {
|
|
10
11
|
const user = auth(req, res);
|
|
11
12
|
if (!user)
|
|
12
13
|
return;
|
|
13
14
|
if (!rateLimitOk(`like:${user.id}`, 60, 60_000))
|
|
14
15
|
return void res.status(429).json({ error: '点赞过于频繁' });
|
|
15
|
-
const sh =
|
|
16
|
+
const sh = await dbOne("SELECT id, owner_id, related_product_id, status FROM shareables WHERE id = ?", [req.params.id]);
|
|
16
17
|
if (!sh)
|
|
17
18
|
return void res.status(404).json({ error: 'shareable 不存在' });
|
|
18
19
|
if (sh.status !== 'active')
|
|
@@ -20,7 +21,7 @@ export function registerShareablesInteractionsRoutes(app, deps) {
|
|
|
20
21
|
if (sh.owner_id === user.id)
|
|
21
22
|
return void res.json({ error: '不能给自己点赞' });
|
|
22
23
|
// P1 Sybil 软门槛:至少完成过 1 笔订单(不限购买该商品,只需活跃用户)
|
|
23
|
-
const completed =
|
|
24
|
+
const completed = (await dbOne("SELECT COUNT(1) as n FROM orders WHERE buyer_id = ? AND status = 'completed'", [user.id])).n;
|
|
24
25
|
if (completed < 1)
|
|
25
26
|
return void res.json({ error: '完成首笔购买后才能点赞(防止刷赞)' });
|
|
26
27
|
// P0 fix:SELECT existing 进 transaction
|
|
@@ -42,39 +43,38 @@ export function registerShareablesInteractionsRoutes(app, deps) {
|
|
|
42
43
|
liked = true;
|
|
43
44
|
}
|
|
44
45
|
})();
|
|
45
|
-
const newCount =
|
|
46
|
+
const newCount = (await dbOne('SELECT like_count FROM shareables WHERE id = ?', [req.params.id])).like_count;
|
|
46
47
|
// 通知 owner(仅新增点赞,避免取消时打扰)
|
|
47
48
|
if (liked) {
|
|
48
49
|
try {
|
|
49
|
-
|
|
50
|
-
VALUES (?,?,'shareable_like',?,?,datetime('now'))
|
|
51
|
-
.run(generateId('ntf'), sh.owner_id, `❤️ 收到点赞`, `分享 #${req.params.id.slice(-8)} 被点赞(累计 ${newCount})`);
|
|
50
|
+
await dbRun(`INSERT INTO notifications (id, user_id, type, title, body, created_at)
|
|
51
|
+
VALUES (?,?,'shareable_like',?,?,datetime('now'))`, [generateId('ntf'), sh.owner_id, `❤️ 收到点赞`, `分享 #${req.params.id.slice(-8)} 被点赞(累计 ${newCount})`]);
|
|
52
52
|
}
|
|
53
53
|
catch { }
|
|
54
54
|
}
|
|
55
55
|
res.json({ liked, like_count: newCount });
|
|
56
56
|
});
|
|
57
57
|
// W6 笔记评论 — 楼中楼 1 层(root + replies)
|
|
58
|
-
app.get('/api/shareables/:id/comments', (req, res) => {
|
|
59
|
-
const sh =
|
|
58
|
+
app.get('/api/shareables/:id/comments', async (req, res) => {
|
|
59
|
+
const sh = await dbOne(`SELECT id FROM shareables WHERE id = ?`, [req.params.id]);
|
|
60
60
|
if (!sh)
|
|
61
61
|
return void res.status(404).json({ error: 'shareable 不存在' });
|
|
62
62
|
const limit = Math.min(100, Math.max(10, Number(req.query.limit) || 50));
|
|
63
63
|
const sort = String(req.query.sort || 'newest');
|
|
64
64
|
const orderBy = sort === 'top' ? 'c.likes DESC, c.created_at DESC' : 'c.created_at DESC';
|
|
65
|
-
const roots =
|
|
65
|
+
const roots = await dbAll(`
|
|
66
66
|
SELECT c.*, u.handle, u.name, u.role
|
|
67
67
|
FROM shareable_comments c LEFT JOIN users u ON u.id = c.commenter_id
|
|
68
68
|
WHERE c.shareable_id = ? AND c.parent_id IS NULL AND c.flagged = 0
|
|
69
69
|
ORDER BY ${orderBy} LIMIT ?
|
|
70
|
-
|
|
70
|
+
`, [sh.id, limit]);
|
|
71
71
|
const rootIds = roots.map(r => r.id);
|
|
72
|
-
const replies = rootIds.length > 0 ?
|
|
72
|
+
const replies = rootIds.length > 0 ? await dbAll(`
|
|
73
73
|
SELECT c.*, u.handle, u.name, u.role
|
|
74
74
|
FROM shareable_comments c LEFT JOIN users u ON u.id = c.commenter_id
|
|
75
75
|
WHERE c.parent_id IN (${rootIds.map(() => '?').join(',')}) AND c.flagged = 0
|
|
76
76
|
ORDER BY c.created_at ASC
|
|
77
|
-
|
|
77
|
+
`, rootIds) : [];
|
|
78
78
|
const replyMap = new Map();
|
|
79
79
|
for (const r of replies) {
|
|
80
80
|
const pid = String(r.parent_id);
|
|
@@ -83,21 +83,21 @@ export function registerShareablesInteractionsRoutes(app, deps) {
|
|
|
83
83
|
replyMap.set(pid, arr);
|
|
84
84
|
}
|
|
85
85
|
const items = roots.map(r => ({ ...r, replies: replyMap.get(r.id) || [] }));
|
|
86
|
-
const total =
|
|
86
|
+
const total = (await dbOne(`SELECT COUNT(*) as n FROM shareable_comments WHERE shareable_id = ? AND flagged = 0`, [sh.id])).n;
|
|
87
87
|
res.json({ items, total, sort });
|
|
88
88
|
});
|
|
89
89
|
app.post('/api/shareables/:id/comments', async (req, res) => {
|
|
90
90
|
const user = auth(req, res);
|
|
91
91
|
if (!user)
|
|
92
92
|
return;
|
|
93
|
-
const sh =
|
|
93
|
+
const sh = await dbOne(`SELECT id, owner_id, status FROM shareables WHERE id = ?`, [req.params.id]);
|
|
94
94
|
if (!sh)
|
|
95
95
|
return void res.status(404).json({ error: 'shareable 不存在' });
|
|
96
96
|
if (sh.status !== 'active')
|
|
97
97
|
return void res.status(400).json({ error: 'shareable 已下架' });
|
|
98
98
|
const parentId = req.body?.parent_id ? String(req.body.parent_id) : null;
|
|
99
99
|
if (parentId) {
|
|
100
|
-
const parent =
|
|
100
|
+
const parent = await dbOne(`SELECT id, parent_id FROM shareable_comments WHERE id = ? AND shareable_id = ?`, [parentId, sh.id]);
|
|
101
101
|
if (!parent)
|
|
102
102
|
return void res.status(404).json({ error: '父评论不存在' });
|
|
103
103
|
if (parent.parent_id)
|
|
@@ -120,14 +120,13 @@ export function registerShareablesInteractionsRoutes(app, deps) {
|
|
|
120
120
|
// 同仲裁评论:flagged 给管理员,flag_reasons 给反诈;用 rawBody
|
|
121
121
|
const reasons = detectFraud(rawBody);
|
|
122
122
|
const cid = generateId('scom');
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
await dbRun(`INSERT INTO shareable_comments (id, shareable_id, commenter_id, parent_id, body, flag_reasons) VALUES (?,?,?,?,?,?)`, [cid, sh.id, user.id, parentId, body,
|
|
124
|
+
reasons.length ? JSON.stringify(reasons) : null]);
|
|
125
125
|
// 通知作者(自己评论自己除外)+ W9 action
|
|
126
126
|
if (sh.owner_id !== user.id) {
|
|
127
127
|
try {
|
|
128
128
|
const actions = JSON.stringify([{ kind: 'navigate', label: '查看笔记', href: `#note/${sh.id}`, style: 'primary' }]);
|
|
129
|
-
|
|
130
|
-
.run(generateId('ntf'), sh.owner_id, 'note_comment', parentId ? '💬 笔记评论新回复' : '💬 笔记新评论', body.slice(0, 80), null, actions);
|
|
129
|
+
await dbRun(`INSERT INTO notifications (id, user_id, type, title, body, order_id, actions) VALUES (?,?,?,?,?,?,?)`, [generateId('ntf'), sh.owner_id, 'note_comment', parentId ? '💬 笔记评论新回复' : '💬 笔记新评论', body.slice(0, 80), null, actions]);
|
|
131
130
|
}
|
|
132
131
|
catch (e) {
|
|
133
132
|
console.warn('[notif note_comment]', e.message);
|
|
@@ -139,43 +138,43 @@ export function registerShareablesInteractionsRoutes(app, deps) {
|
|
|
139
138
|
res.json({ success: true, id: cid, flag_reasons: reasons, mentions: commentMentions.map(m => m.handle) });
|
|
140
139
|
});
|
|
141
140
|
// 查询单个 shareable 我是否点赞过(用于 UI 状态)
|
|
142
|
-
app.get('/api/shareables/:id/like-status', (req, res) => {
|
|
141
|
+
app.get('/api/shareables/:id/like-status', async (req, res) => {
|
|
143
142
|
const user = auth(req, res);
|
|
144
143
|
if (!user)
|
|
145
144
|
return;
|
|
146
|
-
const row =
|
|
147
|
-
const count =
|
|
145
|
+
const row = await dbOne('SELECT id FROM shareable_likes WHERE shareable_id = ? AND user_id = ?', [req.params.id, user.id]);
|
|
146
|
+
const count = (await dbOne('SELECT like_count FROM shareables WHERE id = ?', [req.params.id]))?.like_count ?? 0;
|
|
148
147
|
res.json({ liked: !!row, like_count: count });
|
|
149
148
|
});
|
|
150
149
|
// ─── 收藏 Bookmarks(小红书风格"收藏" tab)── 2026-05-22 audit ─────
|
|
151
150
|
// POST 切换:未收藏 → 加 / 已收藏 → 删(toggle 模式)
|
|
152
|
-
app.post('/api/shareables/:id/bookmark', (req, res) => {
|
|
151
|
+
app.post('/api/shareables/:id/bookmark', async (req, res) => {
|
|
153
152
|
const user = auth(req, res);
|
|
154
153
|
if (!user)
|
|
155
154
|
return;
|
|
156
155
|
const id = String(req.params.id);
|
|
157
156
|
// 确认 shareable 存在 + active
|
|
158
|
-
const sh =
|
|
157
|
+
const sh = await dbOne("SELECT id FROM shareables WHERE id = ? AND status = 'active'", [id]);
|
|
159
158
|
if (!sh)
|
|
160
159
|
return void res.status(404).json({ error: 'not_found' });
|
|
161
|
-
const existing =
|
|
160
|
+
const existing = await dbOne('SELECT id FROM shareable_bookmarks WHERE shareable_id = ? AND user_id = ?', [id, user.id]);
|
|
162
161
|
if (existing) {
|
|
163
|
-
|
|
162
|
+
await dbRun('DELETE FROM shareable_bookmarks WHERE id = ?', [existing.id]);
|
|
164
163
|
return void res.json({ bookmarked: false });
|
|
165
164
|
}
|
|
166
|
-
|
|
165
|
+
await dbRun('INSERT INTO shareable_bookmarks (id, shareable_id, user_id) VALUES (?, ?, ?)', [generateId('bm'), id, user.id]);
|
|
167
166
|
res.json({ bookmarked: true });
|
|
168
167
|
});
|
|
169
168
|
// 查 bookmark 状态
|
|
170
|
-
app.get('/api/shareables/:id/bookmark-status', (req, res) => {
|
|
169
|
+
app.get('/api/shareables/:id/bookmark-status', async (req, res) => {
|
|
171
170
|
const user = auth(req, res);
|
|
172
171
|
if (!user)
|
|
173
172
|
return;
|
|
174
|
-
const row =
|
|
173
|
+
const row = await dbOne('SELECT id FROM shareable_bookmarks WHERE shareable_id = ? AND user_id = ?', [req.params.id, user.id]);
|
|
175
174
|
res.json({ bookmarked: !!row });
|
|
176
175
|
});
|
|
177
176
|
// 我收藏过的 shareables(仅 owner 自己可见)
|
|
178
|
-
app.get('/api/users/:id/bookmarked-shareables', (req, res) => {
|
|
177
|
+
app.get('/api/users/:id/bookmarked-shareables', async (req, res) => {
|
|
179
178
|
const me = auth(req, res);
|
|
180
179
|
if (!me)
|
|
181
180
|
return;
|
|
@@ -185,7 +184,7 @@ export function registerShareablesInteractionsRoutes(app, deps) {
|
|
|
185
184
|
ownerId = me.id;
|
|
186
185
|
if (!ownerId)
|
|
187
186
|
return void res.status(403).json({ error: 'only owner can view bookmarks' });
|
|
188
|
-
const rows =
|
|
187
|
+
const rows = await dbAll(`
|
|
189
188
|
SELECT s.id, s.owner_id, s.owner_code, s.type, s.external_url, s.external_platform,
|
|
190
189
|
s.thumbnail_url, s.title, s.description, s.photo_hashes, s.related_product_id, s.related_anchor,
|
|
191
190
|
s.click_count, s.like_count, s.created_at,
|
|
@@ -196,7 +195,7 @@ export function registerShareablesInteractionsRoutes(app, deps) {
|
|
|
196
195
|
LEFT JOIN products p ON p.id = s.related_product_id
|
|
197
196
|
WHERE b.user_id = ? AND s.status = 'active'
|
|
198
197
|
ORDER BY b.created_at DESC LIMIT 100
|
|
199
|
-
|
|
198
|
+
`, [ownerId]);
|
|
200
199
|
for (const r of rows) {
|
|
201
200
|
if (typeof r.photo_hashes === 'string') {
|
|
202
201
|
try {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import { writeNotePhoto, readNotePhoto, noteBlobExists, NOTE_PHOTO_MAX_BYTES, NOTE_PHOTO_ALLOWED_MIME } from '../../layer2-business/L2-notes/note-photo-storage.js';
|
|
3
3
|
import { retireAnchorsByTarget } from '../../layer2-business/L2-anchor-registry/anchor-registry.js';
|
|
4
|
+
import { dbOne, dbAll, dbRun } from '../../layer0-foundation/L0-1-database/db.js'; // RFC-016 异步 DB seam
|
|
4
5
|
export const SHAREABLE_DAILY_LIMIT = 10;
|
|
5
6
|
export function registerShareablesRoutes(app, deps) {
|
|
6
7
|
const { db, auth, getUser, generateId, lightAuthGuard, detectExternalPlatform, noteAuthenticityBadges, parseHashtags, parseMentions, notifyMentions, flagNewAccountShareable, refreshProductSharerCount } = deps;
|
|
@@ -47,7 +48,7 @@ export function registerShareablesRoutes(app, deps) {
|
|
|
47
48
|
}
|
|
48
49
|
});
|
|
49
50
|
// 创建 shareable — 双路径:笔记模式 / 外链或 native_text 模式
|
|
50
|
-
app.post('/api/shareables', (req, res) => {
|
|
51
|
+
app.post('/api/shareables', async (req, res) => {
|
|
51
52
|
const me = auth(req, res);
|
|
52
53
|
if (!me)
|
|
53
54
|
return;
|
|
@@ -61,7 +62,7 @@ export function registerShareablesRoutes(app, deps) {
|
|
|
61
62
|
// ─── 笔记模式专属校验 ───────────────────────────────────────
|
|
62
63
|
if (!related_order_id)
|
|
63
64
|
return void res.json({ error: '笔记必须关联订单(你购买过的 completed 订单)' });
|
|
64
|
-
const order =
|
|
65
|
+
const order = await dbOne(`SELECT id, buyer_id, seller_id, product_id, status FROM orders WHERE id = ?`, [related_order_id]);
|
|
65
66
|
if (!order)
|
|
66
67
|
return void res.status(404).json({ error: '订单不存在' });
|
|
67
68
|
if (order.buyer_id !== me.id)
|
|
@@ -69,7 +70,7 @@ export function registerShareablesRoutes(app, deps) {
|
|
|
69
70
|
if (order.status !== 'completed')
|
|
70
71
|
return void res.json({ error: '订单完成后才能发笔记' });
|
|
71
72
|
// 每订单 1 篇原创(转发不算 — 转发用 parent_id)
|
|
72
|
-
const dupOrder =
|
|
73
|
+
const dupOrder = await dbOne(`SELECT id FROM shareables WHERE owner_id = ? AND related_order_id = ? AND type = 'note' AND parent_id IS NULL AND status != 'removed' LIMIT 1`, [me.id, related_order_id]);
|
|
73
74
|
if (dupOrder && !parent_id)
|
|
74
75
|
return void res.json({ error: '该订单已发过原创笔记', existing_id: dupOrder.id });
|
|
75
76
|
if (trimText.length < 30)
|
|
@@ -89,7 +90,7 @@ export function registerShareablesRoutes(app, deps) {
|
|
|
89
90
|
// 图 hash 跨笔记唯一(防剽窃)— 审计修 C-1
|
|
90
91
|
const hashList = photo_hashes;
|
|
91
92
|
for (const h of hashList) {
|
|
92
|
-
const existing =
|
|
93
|
+
const existing = await dbOne(`SELECT shareable_id FROM note_photo_index WHERE hash = ?`, [h]);
|
|
93
94
|
if (existing && existing.shareable_id) {
|
|
94
95
|
return void res.json({
|
|
95
96
|
error: `图片已被其它笔记使用(疑似剽窃):${h.slice(0, 12)}…`,
|
|
@@ -100,25 +101,27 @@ export function registerShareablesRoutes(app, deps) {
|
|
|
100
101
|
const productId = order.product_id;
|
|
101
102
|
// parent_id 校验(转发链)
|
|
102
103
|
if (parent_id) {
|
|
103
|
-
const parent =
|
|
104
|
+
const parent = await dbOne(`SELECT id, related_product_id FROM shareables WHERE id = ? AND status != 'removed'`, [parent_id]);
|
|
104
105
|
if (!parent)
|
|
105
106
|
return void res.json({ error: '原笔记不存在' });
|
|
106
107
|
if (parent.related_product_id !== productId)
|
|
107
108
|
return void res.json({ error: '转发必须基于同一商品的笔记' });
|
|
108
109
|
}
|
|
109
110
|
// 日上限
|
|
110
|
-
const todayCount =
|
|
111
|
+
const todayCount = (await dbOne(`SELECT COUNT(*) as n FROM shareables WHERE owner_id = ? AND created_at > datetime('now', '-1 day')`, [me.id])).n;
|
|
111
112
|
if (todayCount >= SHAREABLE_DAILY_LIMIT)
|
|
112
113
|
return void res.json({ error: `每日上限 ${SHAREABLE_DAILY_LIMIT} 条,请明天再来` });
|
|
113
114
|
const id = generateId('shr');
|
|
114
|
-
const ownerCode =
|
|
115
|
-
|
|
115
|
+
const ownerCode = (await dbOne("SELECT permanent_code FROM users WHERE id = ?", [me.id]))?.permanent_code || null;
|
|
116
|
+
await dbRun(`INSERT INTO shareables
|
|
116
117
|
(id, owner_id, type, native_text, title, description, related_product_id, related_order_id, parent_id, photo_hashes, owner_code)
|
|
117
|
-
VALUES (?,?,?,?,?,?,?,?,?,?,?)
|
|
118
|
-
|
|
118
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?)`, [id, me.id, 'note', trimText,
|
|
119
|
+
(title || null), (description || null),
|
|
120
|
+
productId, related_order_id, parent_id || null,
|
|
121
|
+
JSON.stringify(hashList), ownerCode]);
|
|
119
122
|
for (const h of hashList) {
|
|
120
123
|
try {
|
|
121
|
-
|
|
124
|
+
await dbRun(`INSERT OR IGNORE INTO note_photo_index (hash, shareable_id) VALUES (?,?)`, [h, id]);
|
|
122
125
|
}
|
|
123
126
|
catch { }
|
|
124
127
|
}
|
|
@@ -126,7 +129,7 @@ export function registerShareablesRoutes(app, deps) {
|
|
|
126
129
|
const tags = parseHashtags((title || '') + ' ' + trimText);
|
|
127
130
|
for (const tg of tags) {
|
|
128
131
|
try {
|
|
129
|
-
|
|
132
|
+
await dbRun(`INSERT OR IGNORE INTO shareable_tags (shareable_id, tag) VALUES (?,?)`, [id, tg]);
|
|
130
133
|
}
|
|
131
134
|
catch { }
|
|
132
135
|
}
|
|
@@ -148,16 +151,16 @@ export function registerShareablesRoutes(app, deps) {
|
|
|
148
151
|
return void res.json({ error: '标题不能超过 100 字' });
|
|
149
152
|
if ((description || '').length > 200)
|
|
150
153
|
return void res.json({ error: '描述不能超过 200 字' });
|
|
151
|
-
const todayCount =
|
|
154
|
+
const todayCount = (await dbOne(`SELECT COUNT(*) as n FROM shareables WHERE owner_id = ? AND created_at > datetime('now', '-1 day')`, [me.id])).n;
|
|
152
155
|
if (todayCount >= SHAREABLE_DAILY_LIMIT)
|
|
153
156
|
return void res.json({ error: `每日上限 ${SHAREABLE_DAILY_LIMIT} 条,请明天再来` });
|
|
154
157
|
if (trimUrl) {
|
|
155
|
-
const dup =
|
|
158
|
+
const dup = await dbOne(`SELECT id FROM shareables WHERE owner_id = ? AND external_url = ? AND status != 'removed' LIMIT 1`, [me.id, trimUrl]);
|
|
156
159
|
if (dup)
|
|
157
160
|
return void res.json({ error: '已存在相同链接,请编辑现有条目', existing_id: dup.id });
|
|
158
161
|
}
|
|
159
162
|
if (related_product_id) {
|
|
160
|
-
const p =
|
|
163
|
+
const p = await dbOne("SELECT id FROM products WHERE id = ?", [related_product_id]);
|
|
161
164
|
if (!p)
|
|
162
165
|
return void res.json({ error: '关联商品不存在' });
|
|
163
166
|
}
|
|
@@ -165,58 +168,59 @@ export function registerShareablesRoutes(app, deps) {
|
|
|
165
168
|
const { type, platform, video_id, thumbnail } = trimUrl
|
|
166
169
|
? detectExternalPlatform(trimUrl)
|
|
167
170
|
: { type: 'native_text', platform: 'native', video_id: undefined, thumbnail: undefined };
|
|
168
|
-
const ownerCode =
|
|
169
|
-
|
|
170
|
-
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
171
|
-
|
|
171
|
+
const ownerCode = (await dbOne("SELECT permanent_code FROM users WHERE id = ?", [me.id]))?.permanent_code || null;
|
|
172
|
+
await dbRun(`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)
|
|
173
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, [id, me.id, type, trimUrl || null, platform, video_id || null, thumbnail || null,
|
|
174
|
+
(title || null), (description || null), trimText || null,
|
|
175
|
+
related_product_id || null, related_anchor || null, ownerCode]);
|
|
172
176
|
flagNewAccountShareable(id, me.id);
|
|
173
177
|
if (related_product_id)
|
|
174
178
|
refreshProductSharerCount(related_product_id);
|
|
175
179
|
res.json({ ok: true, id, type, platform, thumbnail, owner_code: ownerCode });
|
|
176
180
|
});
|
|
177
|
-
app.get('/api/shareables/me', (req, res) => {
|
|
181
|
+
app.get('/api/shareables/me', async (req, res) => {
|
|
178
182
|
const me = auth(req, res);
|
|
179
183
|
if (!me)
|
|
180
184
|
return;
|
|
181
|
-
const rows =
|
|
185
|
+
const rows = await dbAll(`
|
|
182
186
|
SELECT s.*, p.title as product_title FROM shareables s
|
|
183
187
|
LEFT JOIN products p ON p.id = s.related_product_id
|
|
184
188
|
WHERE s.owner_id = ? AND s.status != 'removed'
|
|
185
189
|
ORDER BY s.created_at DESC LIMIT 100
|
|
186
|
-
|
|
190
|
+
`, [me.id]);
|
|
187
191
|
res.json({ shareables: rows });
|
|
188
192
|
});
|
|
189
193
|
// 里程碑 L3:创作者贡献仪表盘
|
|
190
|
-
app.get('/api/creator/stats', (req, res) => {
|
|
194
|
+
app.get('/api/creator/stats', async (req, res) => {
|
|
191
195
|
const me = auth(req, res);
|
|
192
196
|
if (!me)
|
|
193
197
|
return;
|
|
194
198
|
const meId = me.id;
|
|
195
|
-
const shareables =
|
|
199
|
+
const shareables = await dbAll(`
|
|
196
200
|
SELECT id, related_product_id, click_count, unique_click_count, flag_new_account, created_at
|
|
197
201
|
FROM shareables WHERE owner_id = ? AND status != 'removed'
|
|
198
|
-
|
|
202
|
+
`, [meId]);
|
|
199
203
|
const totalShares = shareables.length;
|
|
200
204
|
const productShares = shareables.filter(s => s.related_product_id);
|
|
201
205
|
const uniqueProducts = new Set(productShares.map(s => s.related_product_id)).size;
|
|
202
206
|
const rawClicks = shareables.reduce((a, s) => a + (s.click_count || 0), 0);
|
|
203
207
|
const uniqueClicks = shareables.reduce((a, s) => a + (s.unique_click_count || 0), 0);
|
|
204
208
|
const newAccountFlagged = shareables.filter(s => s.flag_new_account).length;
|
|
205
|
-
const conversions =
|
|
209
|
+
const conversions = (await dbOne(`
|
|
206
210
|
SELECT COUNT(*) as n FROM product_share_attribution psa
|
|
207
211
|
JOIN orders o ON o.product_id = psa.product_id AND o.buyer_id = psa.recipient_id
|
|
208
212
|
WHERE psa.sharer_id = ? AND o.status = 'completed' AND o.created_at >= psa.created_at
|
|
209
|
-
|
|
210
|
-
const l1Earn =
|
|
213
|
+
`, [meId])).n;
|
|
214
|
+
const l1Earn = (await dbOne(`
|
|
211
215
|
SELECT COALESCE(SUM(amount), 0) as total
|
|
212
216
|
FROM commission_records WHERE beneficiary_id = ? AND level = 1
|
|
213
|
-
|
|
217
|
+
`, [meId])).total;
|
|
214
218
|
// #7 按 source_type 分项 — 笔记 vs 普通分享 vs sponsor 链
|
|
215
|
-
const bySource =
|
|
219
|
+
const bySource = await dbAll(`
|
|
216
220
|
SELECT source_type, COALESCE(SUM(amount), 0) as total, COUNT(*) as cnt
|
|
217
221
|
FROM commission_records WHERE beneficiary_id = ?
|
|
218
222
|
GROUP BY source_type
|
|
219
|
-
|
|
223
|
+
`, [meId]);
|
|
220
224
|
const sourceBreakdown = { note: 0, link: 0, sponsor: 0 };
|
|
221
225
|
const sourceCntBreakdown = { note: 0, link: 0, sponsor: 0 };
|
|
222
226
|
for (const r of bySource) {
|
|
@@ -225,13 +229,13 @@ export function registerShareablesRoutes(app, deps) {
|
|
|
225
229
|
sourceCntBreakdown[k] += Number(r.cnt) || 0;
|
|
226
230
|
}
|
|
227
231
|
// 30 天点击趋势
|
|
228
|
-
const trend30d =
|
|
232
|
+
const trend30d = await dbAll(`
|
|
229
233
|
SELECT substr(created_at, 1, 10) as day, COUNT(*) as raw_clicks, COUNT(DISTINCT ip_hash || ':' || ua_hash) as unique_clicks
|
|
230
234
|
FROM shareable_click_log
|
|
231
235
|
WHERE shareable_id IN (SELECT id FROM shareables WHERE owner_id = ?)
|
|
232
236
|
AND created_at > datetime('now', '-30 days')
|
|
233
237
|
GROUP BY day ORDER BY day ASC
|
|
234
|
-
|
|
238
|
+
`, [meId]);
|
|
235
239
|
res.json({
|
|
236
240
|
shares: { total: totalShares, product_count: uniqueProducts, new_account_flagged: newAccountFlagged },
|
|
237
241
|
clicks: { raw: rawClicks, unique: uniqueClicks, raw_to_unique_ratio: rawClicks > 0 ? Math.round(uniqueClicks / rawClicks * 100) / 100 : null },
|
|
@@ -251,11 +255,11 @@ export function registerShareablesRoutes(app, deps) {
|
|
|
251
255
|
});
|
|
252
256
|
});
|
|
253
257
|
// 策展引用:按 click*1 + like*3 + induced_orders*10 加权排序,取 top 10
|
|
254
|
-
app.get('/api/shareables/by-product/:pid', (req, res) => {
|
|
258
|
+
app.get('/api/shareables/by-product/:pid', async (req, res) => {
|
|
255
259
|
const user = auth(req, res);
|
|
256
260
|
if (!user)
|
|
257
261
|
return;
|
|
258
|
-
const rows =
|
|
262
|
+
const rows = await dbAll(`
|
|
259
263
|
SELECT * FROM (
|
|
260
264
|
SELECT s.*, u.name as owner_name, u.handle as owner_handle,
|
|
261
265
|
(SELECT COUNT(DISTINCT o.id) FROM orders o
|
|
@@ -268,29 +272,29 @@ export function registerShareablesRoutes(app, deps) {
|
|
|
268
272
|
) sub
|
|
269
273
|
ORDER BY (click_count * 1.0 + like_count * 3.0 + induced_orders * 10.0) DESC, created_at DESC
|
|
270
274
|
LIMIT 10
|
|
271
|
-
|
|
275
|
+
`, [req.params.pid]);
|
|
272
276
|
for (const r of rows) {
|
|
273
277
|
r.badges = noteAuthenticityBadges(r);
|
|
274
278
|
}
|
|
275
279
|
res.json({ shareables: rows });
|
|
276
280
|
});
|
|
277
|
-
app.get('/api/shareables/by-anchor/:anchor', (req, res) => {
|
|
281
|
+
app.get('/api/shareables/by-anchor/:anchor', async (req, res) => {
|
|
278
282
|
const user = auth(req, res);
|
|
279
283
|
if (!user)
|
|
280
284
|
return;
|
|
281
|
-
const rows =
|
|
285
|
+
const rows = await dbAll(`
|
|
282
286
|
SELECT s.*, u.name as owner_name FROM shareables s
|
|
283
287
|
LEFT JOIN users u ON u.id = s.owner_id
|
|
284
288
|
WHERE s.related_anchor = ? AND s.status = 'active'
|
|
285
289
|
ORDER BY s.created_at DESC LIMIT 50
|
|
286
|
-
|
|
290
|
+
`, [req.params.anchor]);
|
|
287
291
|
res.json({ shareables: rows });
|
|
288
292
|
});
|
|
289
293
|
// Phase D2 笔记 list — 公开 feed,3 种 sort
|
|
290
294
|
// sort=newest: created_at DESC
|
|
291
295
|
// sort=trending: (likes*2 + click/10 + freshness/(age_hours+1)) DESC
|
|
292
296
|
// sort=following: 需登录,仅显示 follows.followee_id 的笔记
|
|
293
|
-
app.get('/api/notes', (req, res) => {
|
|
297
|
+
app.get('/api/notes', async (req, res) => {
|
|
294
298
|
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 20));
|
|
295
299
|
const cursor = req.query.cursor ? String(req.query.cursor) : null;
|
|
296
300
|
const sort = String(req.query.sort || 'newest');
|
|
@@ -326,7 +330,7 @@ export function registerShareablesRoutes(app, deps) {
|
|
|
326
330
|
LIMIT ?
|
|
327
331
|
`;
|
|
328
332
|
args.push(limit + 1);
|
|
329
|
-
const rows =
|
|
333
|
+
const rows = await dbAll(sql, args);
|
|
330
334
|
const hasMore = rows.length > limit;
|
|
331
335
|
const items = rows.slice(0, limit).map(r => {
|
|
332
336
|
let photos = [];
|
|
@@ -351,16 +355,16 @@ export function registerShareablesRoutes(app, deps) {
|
|
|
351
355
|
res.json({ items, next_cursor: nextCursor, sort });
|
|
352
356
|
});
|
|
353
357
|
// Phase C 笔记公开读 — 任何人可读
|
|
354
|
-
app.get('/api/shareables/:id', (req, res) => {
|
|
358
|
+
app.get('/api/shareables/:id', async (req, res) => {
|
|
355
359
|
const id = String(req.params.id);
|
|
356
|
-
const row =
|
|
360
|
+
const row = await dbOne(`
|
|
357
361
|
SELECT s.id, s.owner_id, s.owner_code, s.type, s.title, s.description, s.native_text,
|
|
358
362
|
s.related_product_id, s.related_order_id, s.parent_id, s.photo_hashes,
|
|
359
363
|
s.click_count, s.unique_click_count, s.like_count, s.created_at, s.status,
|
|
360
364
|
u.handle as owner_handle, u.name as owner_name, u.region as owner_region
|
|
361
365
|
FROM shareables s LEFT JOIN users u ON u.id = s.owner_id
|
|
362
366
|
WHERE s.id = ? AND s.status = 'active'
|
|
363
|
-
|
|
367
|
+
`, [id]);
|
|
364
368
|
if (!row)
|
|
365
369
|
return void res.status(404).json({ error: 'not_found' });
|
|
366
370
|
let photos = [];
|
|
@@ -370,9 +374,9 @@ export function registerShareablesRoutes(app, deps) {
|
|
|
370
374
|
catch { }
|
|
371
375
|
let product = null;
|
|
372
376
|
if (row.related_product_id) {
|
|
373
|
-
product =
|
|
377
|
+
product = (await dbOne(`SELECT id, title, price, category, images FROM products WHERE id = ?`, [row.related_product_id])) ?? null;
|
|
374
378
|
}
|
|
375
|
-
const tags =
|
|
379
|
+
const tags = (await dbAll(`SELECT tag FROM shareable_tags WHERE shareable_id = ? ORDER BY id`, [id])).map(r => r.tag);
|
|
376
380
|
const badges = noteAuthenticityBadges(row);
|
|
377
381
|
res.json({
|
|
378
382
|
id: row.id, type: row.type,
|
|
@@ -389,11 +393,11 @@ export function registerShareablesRoutes(app, deps) {
|
|
|
389
393
|
badges,
|
|
390
394
|
});
|
|
391
395
|
});
|
|
392
|
-
app.patch('/api/shareables/:id', (req, res) => {
|
|
396
|
+
app.patch('/api/shareables/:id', async (req, res) => {
|
|
393
397
|
const me = auth(req, res);
|
|
394
398
|
if (!me)
|
|
395
399
|
return;
|
|
396
|
-
const row =
|
|
400
|
+
const row = await dbOne("SELECT owner_id FROM shareables WHERE id = ?", [req.params.id]);
|
|
397
401
|
if (!row || row.owner_id !== me.id)
|
|
398
402
|
return void res.json({ error: '无权操作' });
|
|
399
403
|
const updates = [];
|
|
@@ -426,14 +430,14 @@ export function registerShareablesRoutes(app, deps) {
|
|
|
426
430
|
return void res.json({ error: '没有可更新字段' });
|
|
427
431
|
updates.push(`updated_at = datetime('now')`);
|
|
428
432
|
values.push(req.params.id);
|
|
429
|
-
|
|
433
|
+
await dbRun(`UPDATE shareables SET ${updates.join(', ')} WHERE id = ?`, values);
|
|
430
434
|
res.json({ ok: true });
|
|
431
435
|
});
|
|
432
|
-
app.delete('/api/shareables/:id', (req, res) => {
|
|
436
|
+
app.delete('/api/shareables/:id', async (req, res) => {
|
|
433
437
|
const me = auth(req, res);
|
|
434
438
|
if (!me)
|
|
435
439
|
return;
|
|
436
|
-
const row =
|
|
440
|
+
const row = await dbOne("SELECT owner_id, related_product_id, like_count, status, type FROM shareables WHERE id = ?", [req.params.id]);
|
|
437
441
|
if (!row || row.owner_id !== me.id)
|
|
438
442
|
return void res.json({ error: '无权操作' });
|
|
439
443
|
if (row.status === 'removed')
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { dbOne, dbRun } from '../../layer0-foundation/L0-1-database/db.js';
|
|
2
|
+
export function registerShopReferralRoutes(app, deps) {
|
|
3
|
+
const { auth, errorRes, internalAuditorId, resolveUserRef, resolveInviteCodeRef } = deps;
|
|
4
|
+
app.post('/api/shop-referral/touch', async (req, res) => {
|
|
5
|
+
const user = auth(req, res);
|
|
6
|
+
if (!user)
|
|
7
|
+
return;
|
|
8
|
+
const recipientId = user.id;
|
|
9
|
+
const { seller_identifier, ref_code, side } = req.body || {};
|
|
10
|
+
if (!seller_identifier || typeof seller_identifier !== 'string')
|
|
11
|
+
return void errorRes(res, 400, 'SELLER_REQUIRED', 'seller_identifier required');
|
|
12
|
+
if (!ref_code || typeof ref_code !== 'string')
|
|
13
|
+
return void errorRes(res, 400, 'REF_REQUIRED', 'ref_code required');
|
|
14
|
+
// referrer = invite code ONLY (permanent_code [+ -L/-R]); usr_xxx / @handle / handle rejected.
|
|
15
|
+
const ref = resolveInviteCodeRef(ref_code);
|
|
16
|
+
if (!ref)
|
|
17
|
+
return void errorRes(res, 400, 'INVALID_REF_CODE', '邀请码无效(仅 6-7 位永久码,可带 -L/-R)');
|
|
18
|
+
const referrerId = ref.userId;
|
|
19
|
+
// ref_code 自带的 -L/-R 优先,否则用 body.side
|
|
20
|
+
const finalSide = ref.side || ((side === 'left' || side === 'right') ? side : null);
|
|
21
|
+
// seller 定位:个人页多形态(usr_xxx / @handle / handle / permanent_code)。
|
|
22
|
+
// 必须是真实 seller 店铺 —— 普通 buyer / admin / 其它角色不能被写成 shop_referral_attribution.seller_id。
|
|
23
|
+
const sellerId = resolveUserRef(seller_identifier);
|
|
24
|
+
if (!sellerId)
|
|
25
|
+
return void errorRes(res, 404, 'SELLER_NOT_FOUND', '店铺不存在');
|
|
26
|
+
const sellerRow = await dbOne("SELECT role FROM users WHERE id = ?", [sellerId]);
|
|
27
|
+
if (sellerRow?.role !== 'seller')
|
|
28
|
+
return void errorRes(res, 404, 'SELLER_NOT_FOUND', '店铺不存在');
|
|
29
|
+
if ([referrerId, sellerId].some(id => id === 'sys_protocol' || id === internalAuditorId)) {
|
|
30
|
+
return void errorRes(res, 400, 'INVALID_PARTY', '无效推荐关系');
|
|
31
|
+
}
|
|
32
|
+
// 退化关系安全跳过(不报错,不写坏数据)
|
|
33
|
+
if (recipientId === referrerId)
|
|
34
|
+
return void res.json({ ok: true, attributed: false, skipped: 'self_referral', seller_id: sellerId });
|
|
35
|
+
if (recipientId === sellerId)
|
|
36
|
+
return void res.json({ ok: true, attributed: false, skipped: 'recipient_is_seller', seller_id: sellerId });
|
|
37
|
+
// first-touch:已有未过期记录不覆盖;过期记录可被刷新。
|
|
38
|
+
const existing = await dbOne("SELECT referrer_id FROM shop_referral_attribution WHERE seller_id = ? AND recipient_id = ? AND expires_at > datetime('now')", [sellerId, recipientId]);
|
|
39
|
+
if (existing)
|
|
40
|
+
return void res.json({ ok: true, attributed: false, skipped: 'already_locked', seller_id: sellerId });
|
|
41
|
+
const had = await dbOne("SELECT referrer_id FROM shop_referral_attribution WHERE seller_id = ? AND recipient_id = ?", [sellerId, recipientId]);
|
|
42
|
+
try {
|
|
43
|
+
if (had) {
|
|
44
|
+
// 只刷新仍过期的行(WHERE 双保险:并发下另一请求先刷新 → 本次 0 行,不覆盖)
|
|
45
|
+
await dbRun("UPDATE shop_referral_attribution SET referrer_id = ?, ref_code = ?, side = ?, created_at = datetime('now'), expires_at = datetime('now','+30 days'), source = 'shop_referral' WHERE seller_id = ? AND recipient_id = ? AND expires_at <= datetime('now')", [referrerId, ref.code, finalSide, sellerId, recipientId]);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
await dbRun("INSERT INTO shop_referral_attribution (seller_id, recipient_id, referrer_id, ref_code, side, expires_at) VALUES (?,?,?,?,?, datetime('now','+30 days'))", [sellerId, recipientId, referrerId, ref.code, finalSide]);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// SELECT→INSERT 非原子(async seam):并发 first-touch 撞 PRIMARY KEY → 视作已锁定,不 500
|
|
53
|
+
return void res.json({ ok: true, attributed: false, skipped: 'already_locked', seller_id: sellerId });
|
|
54
|
+
}
|
|
55
|
+
res.json({ ok: true, attributed: true, seller_id: sellerId });
|
|
56
|
+
});
|
|
57
|
+
}
|