@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,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L2-7 · Store-and-Forward (SNF)
|
|
3
|
+
*
|
|
4
|
+
* 协议级离线消息队列 — 借鉴 Mobazha / OpenBazaar 的设计
|
|
5
|
+
*
|
|
6
|
+
* 用途:
|
|
7
|
+
* - 用户 A 给用户 B 发消息(订单事件、聊天、争议证据等)
|
|
8
|
+
* - B 此刻不在线 / 没活动 session
|
|
9
|
+
* - A 不必等 B 上线;消息先落 SNF 队列
|
|
10
|
+
* - B 下次进站第一时间 pull inbox → 拿到 A 的留言
|
|
11
|
+
*
|
|
12
|
+
* 与已有 notifications 表的区别:
|
|
13
|
+
* - notifications = 系统对用户的 UI 提示(bell 红点)
|
|
14
|
+
* - snf_messages = 用户对用户的协议级消息信封(可含签名 payload)
|
|
15
|
+
*
|
|
16
|
+
* 设计:
|
|
17
|
+
* - 服务器是 implicit 默认 SNF — 任何用户的消息都可以委托给服务器持有
|
|
18
|
+
* - 用户可在 snf_designations 表声明额外的 SNF peers(可选,未来 P2P)
|
|
19
|
+
* - TTL 默认 30 天,过期自动清
|
|
20
|
+
* - signature 可选,但建议 sender HMAC 签 (与订单签名链同款机制)
|
|
21
|
+
*/
|
|
22
|
+
import crypto from 'crypto';
|
|
23
|
+
import { generateId } from '../../layer0-foundation/L0-1-database/schema.js';
|
|
24
|
+
export const SNF_TTL_DAYS = 30;
|
|
25
|
+
export const SNF_MAX_PAYLOAD = 32 * 1024; // 32KB 单条上限(大附件走 manifest_registry,SNF 只传 hash)
|
|
26
|
+
export const SNF_MAX_RETRIES = 5; // pull → nack 累计超过此次数 → 自动 dead-letter
|
|
27
|
+
export function initSnfSchema(db) {
|
|
28
|
+
db.exec(`
|
|
29
|
+
CREATE TABLE IF NOT EXISTS snf_messages (
|
|
30
|
+
id TEXT PRIMARY KEY,
|
|
31
|
+
sender_id TEXT NOT NULL,
|
|
32
|
+
recipient_id TEXT NOT NULL,
|
|
33
|
+
message_type TEXT NOT NULL,
|
|
34
|
+
payload TEXT NOT NULL, -- canonical JSON
|
|
35
|
+
signature TEXT, -- 可选 HMAC,用 sender api_key 签
|
|
36
|
+
priority INTEGER DEFAULT 0, -- 0 普通 / 1 高(仲裁证据等)
|
|
37
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
38
|
+
delivered_at TEXT, -- recipient pull 时打戳
|
|
39
|
+
expires_at TEXT NOT NULL, -- TTL,默认 now+30d
|
|
40
|
+
related_order_id TEXT -- 可选:与订单关联便于审计
|
|
41
|
+
);
|
|
42
|
+
CREATE TABLE IF NOT EXISTS snf_designations (
|
|
43
|
+
user_id TEXT PRIMARY KEY,
|
|
44
|
+
snf_peers TEXT NOT NULL DEFAULT '[]', -- JSON array of peer_ids
|
|
45
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
46
|
+
);
|
|
47
|
+
`);
|
|
48
|
+
try {
|
|
49
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_snf_inbox ON snf_messages(recipient_id, delivered_at, created_at DESC)');
|
|
50
|
+
}
|
|
51
|
+
catch { }
|
|
52
|
+
try {
|
|
53
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_snf_sender ON snf_messages(sender_id, created_at DESC)');
|
|
54
|
+
}
|
|
55
|
+
catch { }
|
|
56
|
+
try {
|
|
57
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_snf_expire ON snf_messages(expires_at)');
|
|
58
|
+
}
|
|
59
|
+
catch { }
|
|
60
|
+
// Agent 工作流升级(#5):retry / dead-letter 语义
|
|
61
|
+
// delivery_attempts - pull → nack 累计次数
|
|
62
|
+
// last_attempt_at - 上次拉取时间
|
|
63
|
+
// last_error - 最近一次 nack 的错误描述(≤500 字符)
|
|
64
|
+
// dead_letter - 1=已死信化;列表/普通 pull 自动排除
|
|
65
|
+
for (const stmt of [
|
|
66
|
+
'ALTER TABLE snf_messages ADD COLUMN delivery_attempts INTEGER DEFAULT 0',
|
|
67
|
+
'ALTER TABLE snf_messages ADD COLUMN last_attempt_at TEXT',
|
|
68
|
+
'ALTER TABLE snf_messages ADD COLUMN last_error TEXT',
|
|
69
|
+
'ALTER TABLE snf_messages ADD COLUMN dead_letter INTEGER DEFAULT 0',
|
|
70
|
+
]) {
|
|
71
|
+
try {
|
|
72
|
+
db.exec(stmt);
|
|
73
|
+
}
|
|
74
|
+
catch { /* 已存在 */ }
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_snf_dead ON snf_messages(recipient_id, dead_letter)');
|
|
78
|
+
}
|
|
79
|
+
catch { }
|
|
80
|
+
}
|
|
81
|
+
function canonicalSerialize(obj) {
|
|
82
|
+
const keys = Object.keys(obj).sort();
|
|
83
|
+
const sorted = {};
|
|
84
|
+
for (const k of keys)
|
|
85
|
+
sorted[k] = obj[k];
|
|
86
|
+
return JSON.stringify(sorted);
|
|
87
|
+
}
|
|
88
|
+
export function snfSend(db, args) {
|
|
89
|
+
if (args.senderId === args.recipientId)
|
|
90
|
+
throw new Error('snf_self_send_disallowed');
|
|
91
|
+
const sender = db.prepare('SELECT api_key FROM users WHERE id = ?').get(args.senderId);
|
|
92
|
+
if (!sender)
|
|
93
|
+
throw new Error('snf_sender_not_found');
|
|
94
|
+
const recipient = db.prepare('SELECT id FROM users WHERE id = ?').get(args.recipientId);
|
|
95
|
+
if (!recipient)
|
|
96
|
+
throw new Error('snf_recipient_not_found');
|
|
97
|
+
const canon = canonicalSerialize({
|
|
98
|
+
sender_id: args.senderId,
|
|
99
|
+
recipient_id: args.recipientId,
|
|
100
|
+
message_type: args.messageType,
|
|
101
|
+
payload: args.payload,
|
|
102
|
+
related_order_id: args.relatedOrderId || null,
|
|
103
|
+
});
|
|
104
|
+
if (canon.length > SNF_MAX_PAYLOAD)
|
|
105
|
+
throw new Error('snf_payload_too_large');
|
|
106
|
+
const signature = crypto.createHmac('sha256', sender.api_key).update(canon).digest('hex');
|
|
107
|
+
const id = generateId('snf');
|
|
108
|
+
const ttl = args.ttlDays ?? SNF_TTL_DAYS;
|
|
109
|
+
const expiresAt = new Date(Date.now() + ttl * 86400_000).toISOString();
|
|
110
|
+
db.prepare(`
|
|
111
|
+
INSERT INTO snf_messages (id, sender_id, recipient_id, message_type, payload, signature, priority, expires_at, related_order_id)
|
|
112
|
+
VALUES (?,?,?,?,?,?,?,?,?)
|
|
113
|
+
`).run(id, args.senderId, args.recipientId, args.messageType, canon, signature, args.priority || 0, expiresAt, args.relatedOrderId || null);
|
|
114
|
+
return { id, signature };
|
|
115
|
+
}
|
|
116
|
+
// 只读 list — 列出我作为收件人的近期消息(含已 delivered,TTL 内)
|
|
117
|
+
// 用于 UI 显示。不消费 — 刷新 / 重进页面都能看到。
|
|
118
|
+
export function snfListInbox(db, userId, limit = 80, sinceDays = 30) {
|
|
119
|
+
const rows = db.prepare(`
|
|
120
|
+
SELECT id, sender_id, message_type, payload, signature, created_at, delivered_at, priority, related_order_id
|
|
121
|
+
FROM snf_messages
|
|
122
|
+
WHERE recipient_id = ?
|
|
123
|
+
AND dead_letter = 0
|
|
124
|
+
AND datetime(expires_at) > datetime('now')
|
|
125
|
+
AND datetime(created_at) > datetime('now', ?)
|
|
126
|
+
ORDER BY priority DESC, created_at DESC
|
|
127
|
+
LIMIT ?
|
|
128
|
+
`).all(userId, '-' + sinceDays + ' days', limit);
|
|
129
|
+
return rows.map(r => ({
|
|
130
|
+
id: r.id,
|
|
131
|
+
sender_id: r.sender_id,
|
|
132
|
+
message_type: r.message_type,
|
|
133
|
+
payload: (() => { try {
|
|
134
|
+
const c = JSON.parse(r.payload);
|
|
135
|
+
return c?.payload || {};
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return {};
|
|
139
|
+
} })(),
|
|
140
|
+
signature: r.signature || null,
|
|
141
|
+
created_at: r.created_at,
|
|
142
|
+
delivered_at: r.delivered_at || null,
|
|
143
|
+
priority: r.priority || 0,
|
|
144
|
+
related_order_id: r.related_order_id || null,
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
// 显式 ack — 用户点开消息或点 "mark all read" 时调用,把 delivered_at 戳上
|
|
148
|
+
// 幂等:已 ack 的不再覆盖
|
|
149
|
+
export function snfAck(db, userId, msgIds) {
|
|
150
|
+
if (!msgIds.length)
|
|
151
|
+
return { acked: 0 };
|
|
152
|
+
const placeholders = msgIds.map(() => '?').join(',');
|
|
153
|
+
const now = new Date().toISOString();
|
|
154
|
+
const r = db.prepare(`UPDATE snf_messages SET delivered_at = ? WHERE recipient_id = ? AND delivered_at IS NULL AND id IN (${placeholders})`).run(now, userId, ...msgIds);
|
|
155
|
+
return { acked: r.changes };
|
|
156
|
+
}
|
|
157
|
+
// 拉取我作为收件人的未投递消息(自动 mark as delivered;幂等 — 同条不会重复返回)
|
|
158
|
+
// 用途:协议级 / agent 消费场景,不是 UI 入口(UI 应该用 snfListInbox + snfAck)
|
|
159
|
+
// Agent 工作流:成功处理 → 不用调用(已 delivered);处理失败 → 调 snfNack(ids, error) 回放
|
|
160
|
+
export function snfPullInbox(db, userId, limit = 50) {
|
|
161
|
+
const rows = db.prepare(`
|
|
162
|
+
SELECT id, sender_id, message_type, payload, signature, created_at, priority, related_order_id, delivery_attempts
|
|
163
|
+
FROM snf_messages
|
|
164
|
+
WHERE recipient_id = ? AND delivered_at IS NULL AND dead_letter = 0
|
|
165
|
+
AND datetime(expires_at) > datetime('now')
|
|
166
|
+
ORDER BY priority DESC, created_at ASC
|
|
167
|
+
LIMIT ?
|
|
168
|
+
`).all(userId, limit);
|
|
169
|
+
const now = new Date().toISOString();
|
|
170
|
+
const ids = rows.map(r => r.id);
|
|
171
|
+
if (ids.length > 0) {
|
|
172
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
173
|
+
db.prepare(`UPDATE snf_messages SET delivered_at = ?, last_attempt_at = ?, delivery_attempts = COALESCE(delivery_attempts,0) + 1 WHERE id IN (${placeholders})`).run(now, now, ...ids);
|
|
174
|
+
}
|
|
175
|
+
return rows.map(r => ({
|
|
176
|
+
id: r.id,
|
|
177
|
+
sender_id: r.sender_id,
|
|
178
|
+
message_type: r.message_type,
|
|
179
|
+
// 服务端存的是 canonical wrapper(含 sender/recipient/message_type/payload/related_order_id);
|
|
180
|
+
// 返给应用层只暴露 user payload 本身。验证签名时可重建 canonical。
|
|
181
|
+
payload: (() => { try {
|
|
182
|
+
const c = JSON.parse(r.payload);
|
|
183
|
+
return c?.payload || {};
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return {};
|
|
187
|
+
} })(),
|
|
188
|
+
signature: r.signature || null,
|
|
189
|
+
created_at: r.created_at,
|
|
190
|
+
priority: r.priority || 0,
|
|
191
|
+
related_order_id: r.related_order_id || null,
|
|
192
|
+
delivery_attempts: (r.delivery_attempts || 0) + 1, // 加 1 反映本次拉取后的值
|
|
193
|
+
}));
|
|
194
|
+
}
|
|
195
|
+
// Agent 处理失败 → 回放消息(清 delivered_at 让下次 pull 重新拿到)
|
|
196
|
+
// 超过 SNF_MAX_RETRIES 自动死信化(避免无限循环)
|
|
197
|
+
// error 字段最多保留 500 字符
|
|
198
|
+
export function snfNack(db, userId, msgIds, error) {
|
|
199
|
+
if (!msgIds.length)
|
|
200
|
+
return { reopened: 0, deadLettered: 0 };
|
|
201
|
+
const safeError = error ? String(error).slice(0, 500) : null;
|
|
202
|
+
let reopened = 0, deadLettered = 0;
|
|
203
|
+
db.transaction(() => {
|
|
204
|
+
for (const id of msgIds) {
|
|
205
|
+
const row = db.prepare(`SELECT delivery_attempts, recipient_id FROM snf_messages WHERE id = ? AND dead_letter = 0`).get(id);
|
|
206
|
+
if (!row || row.recipient_id !== userId)
|
|
207
|
+
continue;
|
|
208
|
+
const attempts = row.delivery_attempts || 0;
|
|
209
|
+
if (attempts >= SNF_MAX_RETRIES) {
|
|
210
|
+
// 自动死信
|
|
211
|
+
db.prepare(`UPDATE snf_messages SET dead_letter = 1, last_error = ? WHERE id = ? AND recipient_id = ?`).run(safeError, id, userId);
|
|
212
|
+
deadLettered++;
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
// 回放:清 delivered_at + 记 error,下次 pull 会重新拿到
|
|
216
|
+
db.prepare(`UPDATE snf_messages SET delivered_at = NULL, last_error = ? WHERE id = ? AND recipient_id = ?`).run(safeError, id, userId);
|
|
217
|
+
reopened++;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
})();
|
|
221
|
+
return { reopened, deadLettered };
|
|
222
|
+
}
|
|
223
|
+
// 列出死信消息(人工 review 用 — admin / agent 异常排查)
|
|
224
|
+
export function snfListDeadLetter(db, userId, limit = 50) {
|
|
225
|
+
return db.prepare(`
|
|
226
|
+
SELECT id, sender_id, message_type, delivery_attempts, last_error, last_attempt_at, created_at, related_order_id
|
|
227
|
+
FROM snf_messages
|
|
228
|
+
WHERE recipient_id = ? AND dead_letter = 1
|
|
229
|
+
ORDER BY last_attempt_at DESC NULLS LAST, created_at DESC
|
|
230
|
+
LIMIT ?
|
|
231
|
+
`).all(userId, limit);
|
|
232
|
+
}
|
|
233
|
+
// 死信复活:清零 attempts + dead_letter + delivered_at,重新进 active 队列
|
|
234
|
+
// 用于:手动审查发现 transient 错误后想再试
|
|
235
|
+
export function snfRevive(db, userId, msgId) {
|
|
236
|
+
const r = db.prepare(`SELECT recipient_id, dead_letter FROM snf_messages WHERE id = ?`).get(msgId);
|
|
237
|
+
if (!r)
|
|
238
|
+
return { ok: false, reason: 'not_found' };
|
|
239
|
+
if (r.recipient_id !== userId)
|
|
240
|
+
return { ok: false, reason: 'not_owner' };
|
|
241
|
+
if (!r.dead_letter)
|
|
242
|
+
return { ok: false, reason: 'not_dead_letter' };
|
|
243
|
+
db.prepare(`UPDATE snf_messages SET dead_letter = 0, delivered_at = NULL, delivery_attempts = 0, last_error = NULL WHERE id = ?`).run(msgId);
|
|
244
|
+
return { ok: true };
|
|
245
|
+
}
|
|
246
|
+
// 查看 inbox 未读数(不消费)
|
|
247
|
+
export function snfPendingCount(db, userId) {
|
|
248
|
+
const r = db.prepare(`SELECT COUNT(*) as n FROM snf_messages WHERE recipient_id = ? AND delivered_at IS NULL AND dead_letter = 0 AND datetime(expires_at) > datetime('now')`).get(userId);
|
|
249
|
+
return r.n;
|
|
250
|
+
}
|
|
251
|
+
// 验证签名(用 sender 当时的 api_key — 若 key 旋转过则失败)
|
|
252
|
+
export function snfVerify(db, msgId) {
|
|
253
|
+
const r = db.prepare(`SELECT sender_id, payload, signature FROM snf_messages WHERE id = ?`).get(msgId);
|
|
254
|
+
if (!r)
|
|
255
|
+
return { ok: false, reason: 'not_found' };
|
|
256
|
+
if (!r.signature)
|
|
257
|
+
return { ok: false, reason: 'no_signature' };
|
|
258
|
+
const sender = db.prepare('SELECT api_key FROM users WHERE id = ?').get(r.sender_id);
|
|
259
|
+
if (!sender)
|
|
260
|
+
return { ok: false, reason: 'sender_gone' };
|
|
261
|
+
const sig = crypto.createHmac('sha256', sender.api_key).update(r.payload).digest('hex');
|
|
262
|
+
return sig === r.signature ? { ok: true } : { ok: false, reason: 'signature_mismatch' };
|
|
263
|
+
}
|
|
264
|
+
// 用户声明 SNF peers — 服务器永远是 implicit fallback
|
|
265
|
+
export function snfDesignate(db, userId, peers) {
|
|
266
|
+
db.prepare(`
|
|
267
|
+
INSERT INTO snf_designations (user_id, snf_peers, updated_at) VALUES (?,?,datetime('now'))
|
|
268
|
+
ON CONFLICT(user_id) DO UPDATE SET snf_peers = excluded.snf_peers, updated_at = datetime('now')
|
|
269
|
+
`).run(userId, JSON.stringify(peers.slice(0, 5))); // 上限 5 个
|
|
270
|
+
}
|
|
271
|
+
export function snfGetDesignation(db, userId) {
|
|
272
|
+
const r = db.prepare(`SELECT snf_peers FROM snf_designations WHERE user_id = ?`).get(userId);
|
|
273
|
+
if (!r)
|
|
274
|
+
return [];
|
|
275
|
+
try {
|
|
276
|
+
const p = JSON.parse(r.snf_peers);
|
|
277
|
+
return Array.isArray(p) ? p : [];
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// TTL cleanup — 每小时跑一次
|
|
284
|
+
export function snfCleanup(db) {
|
|
285
|
+
const r = db.prepare(`DELETE FROM snf_messages WHERE datetime(expires_at) <= datetime('now')`).run();
|
|
286
|
+
return { removed: r.changes };
|
|
287
|
+
}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
// L2-anchor 流量口令注册中心
|
|
2
|
+
// 设计目标:把"站外内容创作者引流回 WebAZ"的口令路径协议化
|
|
3
|
+
//
|
|
4
|
+
// 口令格式(2026-05-21 起去 tier 内嵌):[prefix][middle]
|
|
5
|
+
// prefix = 用户 handle(ASCII 小写,锁定,防侵权)
|
|
6
|
+
// middle = 4 字符(用户自选,≥1 数字,禁顺序/保留词/视觉混淆 oil,
|
|
7
|
+
// 字母与数字必须分段连续不可交错)
|
|
8
|
+
// tier_letter = F/E/D/C/B/A 仍存表(动态属性),lookup 时返回供 UI 显示,
|
|
9
|
+
// 不再拼入字符串(permalink — 达人升级旧 anchor 仍有效)
|
|
10
|
+
//
|
|
11
|
+
// 状态机:
|
|
12
|
+
// active — target 存活,正常解析 + 写 attribution
|
|
13
|
+
// retired — target 已删/owner 主动撤销,lookup 返"已归档"
|
|
14
|
+
// reclaimable — retired 满 365 天 + owner 优先购 30 天,可被新人注册
|
|
15
|
+
//
|
|
16
|
+
// 6 档 tier(Option B × 10 — A 级为顶级稀有)
|
|
17
|
+
// F: 0
|
|
18
|
+
// E: ≥ 10,000 WAZ 累计推广成交额
|
|
19
|
+
// D: ≥ 100,000
|
|
20
|
+
// C: ≥ 1,000,000
|
|
21
|
+
// B: ≥ 10,000,000
|
|
22
|
+
// A: ≥ 100,000,000
|
|
23
|
+
//
|
|
24
|
+
// ── 唯一性原则(2026-05-21 决策,简单优先)──
|
|
25
|
+
// handle 强制 ASCII-only([a-z0-9._]+)— 不引入 unicode/中文 handle:
|
|
26
|
+
// 1. 全网唯一(DB UNIQUE)— anchor lookup 必须精确指向唯一达人
|
|
27
|
+
// 2. 防视觉混淆钓鱼(异形字/繁简体/全角半角 Unicode confusables)
|
|
28
|
+
// 3. URL 友好(不需 percent-encode,IM 复制不损坏)
|
|
29
|
+
// 4. 口播友好(外站国际观众能输入)
|
|
30
|
+
// 中国达人推荐用:拼音 / 拼音首字母+数字(如 xiaomingzx / xm6688)
|
|
31
|
+
// 中文显示需求暂不支持;若未来需要请加 display_name 字段而非改 handle 规则
|
|
32
|
+
export const ANCHOR_HANDLE_MAX_FOR_USE = 16; // handle 超过此长度不能用作 anchor prefix(2026-05-22 audit:12 → 16,让长 handle 用户也能用 anchor)
|
|
33
|
+
export const ANCHOR_MIDDLE_LEN = 4;
|
|
34
|
+
export const ANCHOR_RECLAIM_COOLDOWN_DAYS = 365; // retired → reclaimable 等待
|
|
35
|
+
export const ANCHOR_PRIORITY_RECLAIM_DAYS = 30; // reclaimable 后原 owner 优先购窗口
|
|
36
|
+
export const ANCHOR_MAX_PER_USER = 100; // active+retired 上限(2026-05-22 audit: 50→100,大达人多类目场景)
|
|
37
|
+
export const ANCHOR_MAX_PER_DAY = 5; // 每天创建上限
|
|
38
|
+
// 6 档 tier 阈值(推广成交额,单位 WAZ)
|
|
39
|
+
export const TIER_THRESHOLDS = {
|
|
40
|
+
F: 0,
|
|
41
|
+
E: 10_000,
|
|
42
|
+
D: 100_000,
|
|
43
|
+
C: 1_000_000,
|
|
44
|
+
B: 10_000_000,
|
|
45
|
+
A: 100_000_000,
|
|
46
|
+
};
|
|
47
|
+
// 保留词(middle 不能匹配)
|
|
48
|
+
const RESERVED_MIDDLES = new Set([
|
|
49
|
+
'admin', 'sys', 'api', 'webaz', 'test', 'null', 'root',
|
|
50
|
+
'help', 'mail', 'user', 'info', 'home', 'www0', 'site',
|
|
51
|
+
]);
|
|
52
|
+
// 顺序串(避免 SEO 滥用)
|
|
53
|
+
const SEQUENTIAL_MIDDLES = new Set([
|
|
54
|
+
'0000', '1111', '2222', '3333', '4444', '5555', '6666', '7777', '8888', '9999',
|
|
55
|
+
'1234', '2345', '3456', '4567', '5678', '6789', '0123', 'abcd', 'qwer',
|
|
56
|
+
]);
|
|
57
|
+
export function initAnchorRegistrySchema(db) {
|
|
58
|
+
db.exec(`
|
|
59
|
+
CREATE TABLE IF NOT EXISTS anchor_registry (
|
|
60
|
+
anchor TEXT PRIMARY KEY, -- 全 anchor 字串 = prefix + middle + tier_letter
|
|
61
|
+
prefix TEXT NOT NULL, -- 锁定的 handle 副本
|
|
62
|
+
middle TEXT NOT NULL, -- 用户选的 4 字符
|
|
63
|
+
tier_letter TEXT NOT NULL, -- F/E/D/C/B/A 锁定
|
|
64
|
+
owner_id TEXT NOT NULL,
|
|
65
|
+
target_kind TEXT NOT NULL, -- user / product / shareable / dispute_case
|
|
66
|
+
target_id TEXT NOT NULL,
|
|
67
|
+
status TEXT NOT NULL DEFAULT 'active', -- active / retired / reclaimable
|
|
68
|
+
retired_at TEXT,
|
|
69
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
70
|
+
hits INTEGER DEFAULT 0,
|
|
71
|
+
last_hit_at TEXT
|
|
72
|
+
)
|
|
73
|
+
`);
|
|
74
|
+
try {
|
|
75
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_anchor_owner_status ON anchor_registry(owner_id, status)");
|
|
76
|
+
}
|
|
77
|
+
catch { }
|
|
78
|
+
try {
|
|
79
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_anchor_retired ON anchor_registry(retired_at) WHERE status = 'retired'");
|
|
80
|
+
}
|
|
81
|
+
catch { }
|
|
82
|
+
try {
|
|
83
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_anchor_target ON anchor_registry(target_kind, target_id)");
|
|
84
|
+
}
|
|
85
|
+
catch { }
|
|
86
|
+
// 2026-05-22: 复合索引 — bought_products 内 anchor_count 子查询
|
|
87
|
+
// owner_id + target_kind + target_id + status 4 列全匹配 → index-only scan
|
|
88
|
+
try {
|
|
89
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_anchor_owner_target_status ON anchor_registry(owner_id, target_kind, target_id, status)");
|
|
90
|
+
}
|
|
91
|
+
catch { }
|
|
92
|
+
}
|
|
93
|
+
// 累计推广成交额 — sum of order.total_amount for orders where user is in commission chain
|
|
94
|
+
// 注意:依赖 commission_records 表(每个 order × level 一行)— 一个 order 同一 user 只会出现一次(不会同时是 L1 和 L2)
|
|
95
|
+
export function userReferralVolume(db, userId) {
|
|
96
|
+
const row = db.prepare(`
|
|
97
|
+
SELECT COALESCE(SUM(o.total_amount), 0) as vol
|
|
98
|
+
FROM commission_records cr
|
|
99
|
+
JOIN orders o ON o.id = cr.order_id
|
|
100
|
+
WHERE cr.beneficiary_id = ? AND o.status = 'completed'
|
|
101
|
+
`).get(userId);
|
|
102
|
+
return Number(row?.vol ?? 0);
|
|
103
|
+
}
|
|
104
|
+
// 根据累计推广成交额计算 tier 字母
|
|
105
|
+
export function computeTierLetter(volume) {
|
|
106
|
+
if (volume >= TIER_THRESHOLDS.A)
|
|
107
|
+
return 'A';
|
|
108
|
+
if (volume >= TIER_THRESHOLDS.B)
|
|
109
|
+
return 'B';
|
|
110
|
+
if (volume >= TIER_THRESHOLDS.C)
|
|
111
|
+
return 'C';
|
|
112
|
+
if (volume >= TIER_THRESHOLDS.D)
|
|
113
|
+
return 'D';
|
|
114
|
+
if (volume >= TIER_THRESHOLDS.E)
|
|
115
|
+
return 'E';
|
|
116
|
+
return 'F';
|
|
117
|
+
}
|
|
118
|
+
// 校验 middle 字符串
|
|
119
|
+
export function validateMiddle(middle) {
|
|
120
|
+
if (!middle || typeof middle !== 'string')
|
|
121
|
+
return { ok: false, reason: 'middle_empty' };
|
|
122
|
+
if (middle.length !== ANCHOR_MIDDLE_LEN)
|
|
123
|
+
return { ok: false, reason: 'middle_must_be_4_chars' };
|
|
124
|
+
const lower = middle.toLowerCase();
|
|
125
|
+
if (!/^[a-z0-9]{4}$/.test(lower))
|
|
126
|
+
return { ok: false, reason: 'middle_alphanumeric_only' };
|
|
127
|
+
if (!/[0-9]/.test(lower))
|
|
128
|
+
return { ok: false, reason: 'middle_must_contain_digit' };
|
|
129
|
+
if (RESERVED_MIDDLES.has(lower))
|
|
130
|
+
return { ok: false, reason: 'middle_reserved' };
|
|
131
|
+
if (SEQUENTIAL_MIDDLES.has(lower))
|
|
132
|
+
return { ok: false, reason: 'middle_sequential_forbidden' };
|
|
133
|
+
return { ok: true };
|
|
134
|
+
}
|
|
135
|
+
// 校验 user 的 handle 是否适合做 anchor prefix
|
|
136
|
+
export function validateHandleForAnchor(handle) {
|
|
137
|
+
if (!handle)
|
|
138
|
+
return { ok: false, reason: 'handle_not_set' };
|
|
139
|
+
if (handle.length < 3)
|
|
140
|
+
return { ok: false, reason: 'handle_too_short' };
|
|
141
|
+
if (handle.length > ANCHOR_HANDLE_MAX_FOR_USE)
|
|
142
|
+
return { ok: false, reason: 'handle_too_long_for_anchor' };
|
|
143
|
+
if (!/^[a-z0-9._]+$/.test(handle))
|
|
144
|
+
return { ok: false, reason: 'handle_invalid_chars' };
|
|
145
|
+
return { ok: true };
|
|
146
|
+
}
|
|
147
|
+
// 计算用户当前活跃 anchor 配额
|
|
148
|
+
export function userAnchorQuotaStats(db, userId) {
|
|
149
|
+
const totalRow = db.prepare(`
|
|
150
|
+
SELECT COUNT(*) as n FROM anchor_registry
|
|
151
|
+
WHERE owner_id = ? AND status != 'reclaimable'
|
|
152
|
+
`).get(userId);
|
|
153
|
+
const todayRow = db.prepare(`
|
|
154
|
+
SELECT COUNT(*) as n FROM anchor_registry
|
|
155
|
+
WHERE owner_id = ? AND created_at > datetime('now', '-1 day')
|
|
156
|
+
`).get(userId);
|
|
157
|
+
return {
|
|
158
|
+
active_plus_retired: Number(totalRow?.n ?? 0),
|
|
159
|
+
today_created: Number(todayRow?.n ?? 0),
|
|
160
|
+
max_total: ANCHOR_MAX_PER_USER,
|
|
161
|
+
max_per_day: ANCHOR_MAX_PER_DAY,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// 检查 target 写入权限
|
|
165
|
+
export function checkAnchorTargetPermission(db, userId, targetKind, targetId) {
|
|
166
|
+
if (targetKind === 'user') {
|
|
167
|
+
if (userId !== targetId)
|
|
168
|
+
return { ok: false, reason: 'user_anchor_must_self' };
|
|
169
|
+
return { ok: true };
|
|
170
|
+
}
|
|
171
|
+
if (targetKind === 'product') {
|
|
172
|
+
// 2026-05-21: 推广员场景 — 允许任何登录用户为 product 创建 anchor
|
|
173
|
+
// anchor 已有 owner_id 锁 prefix(来自 handle),不同用户独立锚定同一商品做推广,prefix 不冲突
|
|
174
|
+
// 商品存在校验仍保留
|
|
175
|
+
const p = db.prepare('SELECT id FROM products WHERE id = ? AND status = ?').get(targetId, 'active');
|
|
176
|
+
if (!p)
|
|
177
|
+
return { ok: false, reason: 'product_not_found' };
|
|
178
|
+
return { ok: true };
|
|
179
|
+
}
|
|
180
|
+
if (targetKind === 'shareable') {
|
|
181
|
+
const s = db.prepare('SELECT owner_id FROM shareables WHERE id = ?').get(targetId);
|
|
182
|
+
if (!s)
|
|
183
|
+
return { ok: false, reason: 'shareable_not_found' };
|
|
184
|
+
if (s.owner_id !== userId)
|
|
185
|
+
return { ok: false, reason: 'shareable_not_owner' };
|
|
186
|
+
return { ok: true };
|
|
187
|
+
}
|
|
188
|
+
if (targetKind === 'dispute_case') {
|
|
189
|
+
// 仲裁案例:当事方(buyer/seller/arbitrator) 或 admin 可指
|
|
190
|
+
const c = db.prepare('SELECT buyer_id, seller_id, arbitrator_id FROM dispute_cases WHERE id = ?').get(targetId);
|
|
191
|
+
if (!c)
|
|
192
|
+
return { ok: false, reason: 'case_not_found' };
|
|
193
|
+
if (c.buyer_id === userId || c.seller_id === userId || c.arbitrator_id === userId)
|
|
194
|
+
return { ok: true };
|
|
195
|
+
const u = db.prepare('SELECT role FROM users WHERE id = ?').get(userId);
|
|
196
|
+
if (u?.role === 'admin')
|
|
197
|
+
return { ok: true };
|
|
198
|
+
return { ok: false, reason: 'case_must_be_party_or_admin' };
|
|
199
|
+
}
|
|
200
|
+
return { ok: false, reason: 'invalid_target_kind' };
|
|
201
|
+
}
|
|
202
|
+
// 生成 anchor(综合校验 + 唯一性 + INSERT)
|
|
203
|
+
export function generateAnchor(db, args) {
|
|
204
|
+
const owner = db.prepare('SELECT id, handle FROM users WHERE id = ?').get(args.ownerId);
|
|
205
|
+
if (!owner)
|
|
206
|
+
return { ok: false, reason: 'owner_not_found' };
|
|
207
|
+
// 1. 校验 handle 可作 prefix
|
|
208
|
+
const handleCheck = validateHandleForAnchor(owner.handle);
|
|
209
|
+
if (!handleCheck.ok)
|
|
210
|
+
return { ok: false, reason: handleCheck.reason };
|
|
211
|
+
const prefix = owner.handle.toLowerCase();
|
|
212
|
+
// 2. 校验 middle
|
|
213
|
+
const middleCheck = validateMiddle(args.middle);
|
|
214
|
+
if (!middleCheck.ok)
|
|
215
|
+
return { ok: false, reason: middleCheck.reason };
|
|
216
|
+
const middle = args.middle.toLowerCase();
|
|
217
|
+
// 3. 校验 target 权限
|
|
218
|
+
const permCheck = checkAnchorTargetPermission(db, args.ownerId, args.targetKind, args.targetId);
|
|
219
|
+
if (!permCheck.ok)
|
|
220
|
+
return { ok: false, reason: permCheck.reason };
|
|
221
|
+
// 4. 计算 tier letter
|
|
222
|
+
const vol = userReferralVolume(db, args.ownerId);
|
|
223
|
+
const tier_letter = computeTierLetter(vol);
|
|
224
|
+
// 5. 拼接 anchor + 唯一性
|
|
225
|
+
// 2026-05-21:去掉 tier_letter 内嵌(permalink 设计)
|
|
226
|
+
// 原因:tier 是动态属性(升级),不该锁在静态字符串;达人升级后旧 anchor 仍有效
|
|
227
|
+
// tier_letter 列保留为表字段,lookup 结果中返回供 UI 显示
|
|
228
|
+
const anchor = `${prefix}${middle}`;
|
|
229
|
+
// 2026-05-22 audit fix:TOCTOU 保护 — 配额检查 + INSERT/UPDATE 必须在同一事务内
|
|
230
|
+
// 之前:read quota → check < N → INSERT 三步分离 → 并发请求可同时读到 quota=N-1 → 双双 INSERT 致 quota=N+1(超限)
|
|
231
|
+
// 现在:transaction 串行化(better-sqlite3 同进程内 transactions 是 immediate lock)
|
|
232
|
+
// 注:throw 由 transaction 自动 rollback,return 值通过外层闭包传递
|
|
233
|
+
try {
|
|
234
|
+
return db.transaction(() => {
|
|
235
|
+
// 6. 配额检查(事务内 read)
|
|
236
|
+
const quota = userAnchorQuotaStats(db, args.ownerId);
|
|
237
|
+
if (quota.active_plus_retired >= ANCHOR_MAX_PER_USER) {
|
|
238
|
+
return { ok: false, reason: `quota_max_total_${ANCHOR_MAX_PER_USER}` };
|
|
239
|
+
}
|
|
240
|
+
if (quota.today_created >= ANCHOR_MAX_PER_DAY) {
|
|
241
|
+
return { ok: false, reason: `quota_max_per_day_${ANCHOR_MAX_PER_DAY}` };
|
|
242
|
+
}
|
|
243
|
+
// 7. 检查 anchor 是否存在 / 是否在 reclaim 优先购窗口
|
|
244
|
+
const existing = db.prepare(`SELECT owner_id, status, retired_at FROM anchor_registry WHERE anchor = ?`).get(anchor);
|
|
245
|
+
if (existing) {
|
|
246
|
+
if (existing.status === 'active')
|
|
247
|
+
return { ok: false, reason: 'anchor_taken' };
|
|
248
|
+
if (existing.status === 'retired')
|
|
249
|
+
return { ok: false, reason: 'anchor_retired_not_yet_reclaimable' };
|
|
250
|
+
if (existing.status === 'reclaimable') {
|
|
251
|
+
// 优先购窗口:reclaim 后 30 天内只有原 owner 可领
|
|
252
|
+
const reclaimableSince = existing.retired_at ? new Date(existing.retired_at.replace(' ', 'T') + 'Z').getTime() + ANCHOR_RECLAIM_COOLDOWN_DAYS * 86400_000 : 0;
|
|
253
|
+
const inPriorityWindow = (Date.now() - reclaimableSince) < (ANCHOR_PRIORITY_RECLAIM_DAYS * 86400_000);
|
|
254
|
+
if (inPriorityWindow && existing.owner_id !== args.ownerId) {
|
|
255
|
+
return { ok: false, reason: 'anchor_in_priority_window' };
|
|
256
|
+
}
|
|
257
|
+
// 允许覆盖(reclaimable 直接 UPDATE 而非 INSERT)
|
|
258
|
+
db.prepare(`UPDATE anchor_registry SET owner_id = ?, prefix = ?, middle = ?, tier_letter = ?, target_kind = ?, target_id = ?, status = 'active', retired_at = NULL, created_at = datetime('now'), hits = 0, last_hit_at = NULL WHERE anchor = ?`)
|
|
259
|
+
.run(args.ownerId, prefix, middle, tier_letter, args.targetKind, args.targetId, anchor);
|
|
260
|
+
return { ok: true, anchor, tier_letter };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// 8. 全新 INSERT(事务保护 — 并发请求会被 SQLite 串行化)
|
|
264
|
+
db.prepare(`
|
|
265
|
+
INSERT INTO anchor_registry (anchor, prefix, middle, tier_letter, owner_id, target_kind, target_id, status)
|
|
266
|
+
VALUES (?,?,?,?,?,?,?, 'active')
|
|
267
|
+
`).run(anchor, prefix, middle, tier_letter, args.ownerId, args.targetKind, args.targetId);
|
|
268
|
+
return { ok: true, anchor, tier_letter };
|
|
269
|
+
}).immediate(); // immediate 模式:开始事务即写锁,避免 deferred 的 SQLITE_BUSY
|
|
270
|
+
}
|
|
271
|
+
catch (e) {
|
|
272
|
+
// UNIQUE 约束冲突(同名 anchor 并发 INSERT)→ 视为 anchor_taken
|
|
273
|
+
if (e.message?.includes('UNIQUE') || e.message?.includes('SQLITE_CONSTRAINT')) {
|
|
274
|
+
return { ok: false, reason: 'anchor_taken' };
|
|
275
|
+
}
|
|
276
|
+
throw e;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// 查 anchor — 不写入,timing-safe(同样调用模式)
|
|
280
|
+
export function lookupAnchor(db, code) {
|
|
281
|
+
if (!code || typeof code !== 'string')
|
|
282
|
+
return { found: false };
|
|
283
|
+
// 容错:用户可能复制带 @ 前缀(来自 UI 显示),去掉
|
|
284
|
+
let normalized = code.toLowerCase().trim().replace(/^@/, '');
|
|
285
|
+
if (!/^[a-z0-9]{6,20}$/.test(normalized))
|
|
286
|
+
return { found: false }; // 形态不对
|
|
287
|
+
const stmt = db.prepare(`
|
|
288
|
+
SELECT status, target_kind, target_id, owner_id, tier_letter, retired_at
|
|
289
|
+
FROM anchor_registry WHERE anchor = ?
|
|
290
|
+
`);
|
|
291
|
+
let row = stmt.get(normalized);
|
|
292
|
+
// 兼容旧格式:如果直接查不到,且末尾是单字母 (tier_letter F/E/D/C/B/A 之一) ,
|
|
293
|
+
// 尝试去掉末尾字母再查(达人观众可能仍口播旧 anchor)
|
|
294
|
+
if (!row && /[a-f]$/.test(normalized)) {
|
|
295
|
+
const stripped = normalized.slice(0, -1);
|
|
296
|
+
if (/^[a-z0-9]{6,20}$/.test(stripped)) {
|
|
297
|
+
row = stmt.get(stripped);
|
|
298
|
+
if (row)
|
|
299
|
+
normalized = stripped;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (!row)
|
|
303
|
+
return { found: false };
|
|
304
|
+
// 更新 hits + last_hit_at(不阻塞返回)
|
|
305
|
+
try {
|
|
306
|
+
db.prepare(`UPDATE anchor_registry SET hits = hits + 1, last_hit_at = datetime('now') WHERE anchor = ?`).run(normalized);
|
|
307
|
+
}
|
|
308
|
+
catch { }
|
|
309
|
+
return {
|
|
310
|
+
found: true,
|
|
311
|
+
status: row.status,
|
|
312
|
+
target_kind: row.target_kind,
|
|
313
|
+
target_id: row.target_id,
|
|
314
|
+
owner_id: row.owner_id,
|
|
315
|
+
tier_letter: row.tier_letter,
|
|
316
|
+
retired_at: row.retired_at || null,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
// owner 主动退役
|
|
320
|
+
export function retireAnchor(db, ownerId, code) {
|
|
321
|
+
const normalized = code.toLowerCase().trim();
|
|
322
|
+
const row = db.prepare(`SELECT owner_id, status FROM anchor_registry WHERE anchor = ?`).get(normalized);
|
|
323
|
+
if (!row)
|
|
324
|
+
return { ok: false, reason: 'not_found' };
|
|
325
|
+
if (row.owner_id !== ownerId)
|
|
326
|
+
return { ok: false, reason: 'not_owner' };
|
|
327
|
+
if (row.status !== 'active')
|
|
328
|
+
return { ok: false, reason: 'not_active' };
|
|
329
|
+
db.prepare(`UPDATE anchor_registry SET status = 'retired', retired_at = datetime('now') WHERE anchor = ?`).run(normalized);
|
|
330
|
+
return { ok: true };
|
|
331
|
+
}
|
|
332
|
+
// 删除 target 时调用(hook 进 product / shareable 删除路径)
|
|
333
|
+
// 批量把所有指向该 target 的 active anchor 设为 retired
|
|
334
|
+
export function retireAnchorsByTarget(db, targetKind, targetId) {
|
|
335
|
+
const r = db.prepare(`
|
|
336
|
+
UPDATE anchor_registry SET status = 'retired', retired_at = datetime('now')
|
|
337
|
+
WHERE target_kind = ? AND target_id = ? AND status = 'active'
|
|
338
|
+
`).run(targetKind, targetId);
|
|
339
|
+
return r.changes || 0;
|
|
340
|
+
}
|
|
341
|
+
// Daily cron:retired → reclaimable(满 365 天)
|
|
342
|
+
export function reclaimRetiredAnchors(db) {
|
|
343
|
+
const r = db.prepare(`
|
|
344
|
+
UPDATE anchor_registry SET status = 'reclaimable'
|
|
345
|
+
WHERE status = 'retired' AND datetime(retired_at) < datetime('now', '-${ANCHOR_RECLAIM_COOLDOWN_DAYS} days')
|
|
346
|
+
`).run();
|
|
347
|
+
return { reclaimed: r.changes || 0 };
|
|
348
|
+
}
|
|
349
|
+
// 2026-05-22 audit P1:90 天无成交 → 自动 retire 闲置 anchor
|
|
350
|
+
// 释放 namespace 让新人能用同名 middle,配合 ANCHOR_MAX_PER_USER=100 升级
|
|
351
|
+
// 保留至少 1 个 active anchor(防止用户失去全部 anchor)
|
|
352
|
+
//
|
|
353
|
+
// 规则:
|
|
354
|
+
// 1. status='active'
|
|
355
|
+
// 2. created_at 已超 90 天
|
|
356
|
+
// 3. hits = 0(从未被 lookup)
|
|
357
|
+
// 4. owner 至少保留 1 个 active anchor(按 created_at DESC 排,跳过最新的)
|
|
358
|
+
//
|
|
359
|
+
// 注:被 retire 后用户可手动 reactive,但 365 天 reclaim 冷却仍生效
|
|
360
|
+
export const ANCHOR_IDLE_RETIRE_DAYS = 90;
|
|
361
|
+
export function retireIdleAnchors(db) {
|
|
362
|
+
// 先找候选 — active + 90+ 天 + hits=0
|
|
363
|
+
const candidates = db.prepare(`
|
|
364
|
+
SELECT anchor, owner_id, created_at, hits FROM anchor_registry
|
|
365
|
+
WHERE status = 'active'
|
|
366
|
+
AND datetime(created_at) < datetime('now', '-${ANCHOR_IDLE_RETIRE_DAYS} days')
|
|
367
|
+
AND COALESCE(hits, 0) = 0
|
|
368
|
+
ORDER BY owner_id, created_at ASC
|
|
369
|
+
`).all();
|
|
370
|
+
if (candidates.length === 0)
|
|
371
|
+
return { retired: 0 };
|
|
372
|
+
// 按 owner 分组,保留每用户最新的 1 个 active anchor(不 retire)
|
|
373
|
+
// 即只 retire 闲置 + 非最新(如果用户全是闲置,至少留 1 个)
|
|
374
|
+
const byOwner = new Map(); // owner_id → anchor[] (按 created_at ASC)
|
|
375
|
+
for (const c of candidates) {
|
|
376
|
+
if (!byOwner.has(c.owner_id))
|
|
377
|
+
byOwner.set(c.owner_id, []);
|
|
378
|
+
byOwner.get(c.owner_id).push(c.anchor);
|
|
379
|
+
}
|
|
380
|
+
let total = 0;
|
|
381
|
+
for (const [ownerId, anchors] of byOwner) {
|
|
382
|
+
// 查该用户当前 active 总数
|
|
383
|
+
const activeCount = db.prepare(`SELECT COUNT(*) as n FROM anchor_registry WHERE owner_id = ? AND status = 'active'`).get(ownerId).n;
|
|
384
|
+
// 候选数最多 = activeCount - 1(保留最新的 1 个)
|
|
385
|
+
const candidatesForUser = anchors.slice(0, Math.max(0, activeCount - 1));
|
|
386
|
+
if (candidatesForUser.length === 0)
|
|
387
|
+
continue;
|
|
388
|
+
const placeholders = candidatesForUser.map(() => '?').join(',');
|
|
389
|
+
const r = db.prepare(`
|
|
390
|
+
UPDATE anchor_registry SET status = 'retired', retired_at = datetime('now')
|
|
391
|
+
WHERE anchor IN (${placeholders}) AND status = 'active'
|
|
392
|
+
`).run(...candidatesForUser);
|
|
393
|
+
total += r.changes || 0;
|
|
394
|
+
}
|
|
395
|
+
return { retired: total };
|
|
396
|
+
}
|