@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.
Files changed (153) hide show
  1. package/LICENSE +48 -0
  2. package/README.md +156 -20
  3. package/dist/layer0-foundation/L0-1-database/schema.js +5 -4
  4. package/dist/layer0-foundation/L0-2-state-machine/engine.js +228 -7
  5. package/dist/layer0-foundation/L0-2-state-machine/order-chain.js +156 -0
  6. package/dist/layer0-foundation/L0-2-state-machine/transitions.js +53 -12
  7. package/dist/layer0-foundation/L0-5-manifest/manifest.js +14 -1
  8. package/dist/layer1-agent/L1-1-mcp-server/auth.js +1 -1
  9. package/dist/layer1-agent/L1-1-mcp-server/server.js +3543 -852
  10. package/dist/layer1-agent/L1-2-external-anchor/anchor-engine.js +324 -0
  11. package/dist/layer1-agent/L1-2-identity/agent-passport.js +100 -0
  12. package/dist/layer2-business/L2-6-notifications/notification-engine.js +72 -5
  13. package/dist/layer2-business/L2-7-snf/snf-engine.js +287 -0
  14. package/dist/layer2-business/L2-anchor-registry/anchor-registry.js +396 -0
  15. package/dist/layer2-business/L2-notes/note-photo-storage.js +133 -0
  16. package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +6 -6
  17. package/dist/layer3-trust/L3-1-dispute-engine/evidence-storage.js +246 -0
  18. package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +95 -1
  19. package/dist/layer4-economics/L4-4-skill-market/skill-engine.js +31 -2
  20. package/dist/layer4-economics/L4-4-skill-market/skill-listing-engine.js +358 -0
  21. package/dist/pwa/public/app.js +31230 -2345
  22. package/dist/pwa/public/i18n.js +5282 -111
  23. package/dist/pwa/public/icon.svg +11 -0
  24. package/dist/pwa/public/index.html +4 -1
  25. package/dist/pwa/public/manifest.json +39 -4
  26. package/dist/pwa/public/openapi.json +5946 -0
  27. package/dist/pwa/public/style.css +278 -5
  28. package/dist/pwa/public/sw.js +41 -2
  29. package/dist/pwa/public/vendor/jsQR.js +10102 -0
  30. package/dist/pwa/public/webaz-logo.png +0 -0
  31. package/dist/pwa/routes/account-deletion.js +53 -0
  32. package/dist/pwa/routes/addresses.js +105 -0
  33. package/dist/pwa/routes/admin-admins.js +151 -0
  34. package/dist/pwa/routes/admin-analytics.js +253 -0
  35. package/dist/pwa/routes/admin-atomic.js +21 -0
  36. package/dist/pwa/routes/admin-catalog.js +64 -0
  37. package/dist/pwa/routes/admin-editor-picks.js +45 -0
  38. package/dist/pwa/routes/admin-events.js +60 -0
  39. package/dist/pwa/routes/admin-health.js +66 -0
  40. package/dist/pwa/routes/admin-moderation.js +120 -0
  41. package/dist/pwa/routes/admin-ops.js +179 -0
  42. package/dist/pwa/routes/admin-protocol-params.js +79 -0
  43. package/dist/pwa/routes/admin-reports.js +154 -0
  44. package/dist/pwa/routes/admin-tokenomics.js +113 -0
  45. package/dist/pwa/routes/admin-users-lifecycle.js +237 -0
  46. package/dist/pwa/routes/admin-users-query.js +390 -0
  47. package/dist/pwa/routes/admin-verifier-flow.js +126 -0
  48. package/dist/pwa/routes/admin-verifier-whitelist.js +111 -0
  49. package/dist/pwa/routes/admin-wallet-ops.js +66 -0
  50. package/dist/pwa/routes/agent-buy.js +215 -0
  51. package/dist/pwa/routes/agent-governance.js +341 -0
  52. package/dist/pwa/routes/agent-reputation.js +34 -0
  53. package/dist/pwa/routes/ai.js +101 -0
  54. package/dist/pwa/routes/analytics.js +272 -0
  55. package/dist/pwa/routes/anchors.js +169 -0
  56. package/dist/pwa/routes/announcements.js +110 -0
  57. package/dist/pwa/routes/arbitrator.js +117 -0
  58. package/dist/pwa/routes/auction.js +436 -0
  59. package/dist/pwa/routes/auth-login.js +40 -0
  60. package/dist/pwa/routes/auth-read.js +66 -0
  61. package/dist/pwa/routes/auth-register.js +138 -0
  62. package/dist/pwa/routes/auth-sessions.js +62 -0
  63. package/dist/pwa/routes/blocklist.js +60 -0
  64. package/dist/pwa/routes/buyer-feeds.js +224 -0
  65. package/dist/pwa/routes/cart.js +155 -0
  66. package/dist/pwa/routes/charity.js +816 -0
  67. package/dist/pwa/routes/chat.js +318 -0
  68. package/dist/pwa/routes/checkin-tasks.js +122 -0
  69. package/dist/pwa/routes/checkout-helpers.js +85 -0
  70. package/dist/pwa/routes/claim-initiators.js +88 -0
  71. package/dist/pwa/routes/claim-verify.js +615 -0
  72. package/dist/pwa/routes/claim-voting.js +114 -0
  73. package/dist/pwa/routes/claim-withdrawals.js +20 -0
  74. package/dist/pwa/routes/coupons.js +165 -0
  75. package/dist/pwa/routes/dashboards.js +99 -0
  76. package/dist/pwa/routes/dispute-cases.js +267 -0
  77. package/dist/pwa/routes/disputes-read.js +358 -0
  78. package/dist/pwa/routes/disputes-write.js +475 -0
  79. package/dist/pwa/routes/evidence.js +86 -0
  80. package/dist/pwa/routes/external-anchors.js +107 -0
  81. package/dist/pwa/routes/feedback.js +270 -0
  82. package/dist/pwa/routes/flash-sales.js +130 -0
  83. package/dist/pwa/routes/follows.js +103 -0
  84. package/dist/pwa/routes/group-buys.js +208 -0
  85. package/dist/pwa/routes/growth.js +199 -0
  86. package/dist/pwa/routes/import-product.js +153 -0
  87. package/dist/pwa/routes/kyc.js +40 -0
  88. package/dist/pwa/routes/leaderboard.js +149 -0
  89. package/dist/pwa/routes/listings.js +281 -0
  90. package/dist/pwa/routes/logistics.js +35 -0
  91. package/dist/pwa/routes/manifests.js +126 -0
  92. package/dist/pwa/routes/me-data.js +101 -0
  93. package/dist/pwa/routes/notifications.js +48 -0
  94. package/dist/pwa/routes/offers.js +96 -0
  95. package/dist/pwa/routes/orders-action.js +285 -0
  96. package/dist/pwa/routes/orders-create.js +339 -0
  97. package/dist/pwa/routes/orders-read.js +180 -0
  98. package/dist/pwa/routes/p2p-products.js +178 -0
  99. package/dist/pwa/routes/payments-governance.js +311 -0
  100. package/dist/pwa/routes/peers.js +34 -0
  101. package/dist/pwa/routes/pin-receipts.js +39 -0
  102. package/dist/pwa/routes/products-aliases.js +119 -0
  103. package/dist/pwa/routes/products-claims.js +60 -0
  104. package/dist/pwa/routes/products-create.js +206 -0
  105. package/dist/pwa/routes/products-crud.js +73 -0
  106. package/dist/pwa/routes/products-links.js +129 -0
  107. package/dist/pwa/routes/products-list.js +424 -0
  108. package/dist/pwa/routes/products-meta.js +155 -0
  109. package/dist/pwa/routes/products-update.js +125 -0
  110. package/dist/pwa/routes/profile-credentials.js +105 -0
  111. package/dist/pwa/routes/profile-identity.js +174 -0
  112. package/dist/pwa/routes/profile-location.js +35 -0
  113. package/dist/pwa/routes/profile-placement.js +70 -0
  114. package/dist/pwa/routes/profile-prefs.js +93 -0
  115. package/dist/pwa/routes/promoter.js +208 -0
  116. package/dist/pwa/routes/public-utils.js +170 -0
  117. package/dist/pwa/routes/push.js +54 -0
  118. package/dist/pwa/routes/ratings.js +220 -0
  119. package/dist/pwa/routes/recover-key.js +100 -0
  120. package/dist/pwa/routes/referral.js +58 -0
  121. package/dist/pwa/routes/reputation.js +34 -0
  122. package/dist/pwa/routes/returns.js +493 -0
  123. package/dist/pwa/routes/reviews.js +81 -0
  124. package/dist/pwa/routes/rfqs.js +443 -0
  125. package/dist/pwa/routes/search.js +172 -0
  126. package/dist/pwa/routes/secondhand.js +278 -0
  127. package/dist/pwa/routes/seller-quota.js +225 -0
  128. package/dist/pwa/routes/share-redirects.js +164 -0
  129. package/dist/pwa/routes/shareables-interactions.js +212 -0
  130. package/dist/pwa/routes/shareables.js +470 -0
  131. package/dist/pwa/routes/shops.js +98 -0
  132. package/dist/pwa/routes/signaling.js +43 -0
  133. package/dist/pwa/routes/skill-market.js +173 -0
  134. package/dist/pwa/routes/skills.js +174 -0
  135. package/dist/pwa/routes/snf.js +126 -0
  136. package/dist/pwa/routes/tags.js +47 -0
  137. package/dist/pwa/routes/trial.js +333 -0
  138. package/dist/pwa/routes/trusted-kpi.js +87 -0
  139. package/dist/pwa/routes/url-claim.js +113 -0
  140. package/dist/pwa/routes/users-public.js +317 -0
  141. package/dist/pwa/routes/variants.js +156 -0
  142. package/dist/pwa/routes/verifier-user.js +107 -0
  143. package/dist/pwa/routes/verify-tasks.js +120 -0
  144. package/dist/pwa/routes/waitlist.js +65 -0
  145. package/dist/pwa/routes/wallet-read.js +218 -0
  146. package/dist/pwa/routes/wallet-write.js +273 -0
  147. package/dist/pwa/routes/webauthn.js +188 -0
  148. package/dist/pwa/routes/webhooks.js +162 -0
  149. package/dist/pwa/routes/welcome.js +226 -0
  150. package/dist/pwa/routes/wishlist-qa.js +135 -0
  151. package/dist/pwa/security/ssrf.js +110 -0
  152. package/dist/pwa/server.js +9247 -2097
  153. 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
+ }