@seasonkoh/webaz 0.1.7 → 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 +3691 -714
- 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 +31947 -0
- package/dist/pwa/public/i18n.js +5751 -0
- package/dist/pwa/public/icon.svg +11 -0
- package/dist/pwa/public/index.html +21 -0
- package/dist/pwa/public/manifest.json +48 -0
- package/dist/pwa/public/openapi.json +5946 -0
- package/dist/pwa/public/style.css +535 -0
- package/dist/pwa/public/sw.js +63 -0
- 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 +9679 -698
- package/package.json +11 -4
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L1-2 · 外置存证锚(External Content Anchor)
|
|
3
|
+
*
|
|
4
|
+
* 协议立场:第三方平台(淘宝 / JD / 抖店 / 小红书 / 1688 / Amazon...)
|
|
5
|
+
* 是 WebAZ 的"外置存储",平台不持有大字节,仅持有:
|
|
6
|
+
* - 卖家对 (URL, canonical_extract) 的 HMAC 签名
|
|
7
|
+
* - content_hash(sha256 of canonical)
|
|
8
|
+
* - 用户共识网络的验证回执
|
|
9
|
+
* - 卖家自己节点的 fallback URL(硬件兜底)
|
|
10
|
+
*
|
|
11
|
+
* 三层兜底:
|
|
12
|
+
* Tier 1 第三方平台 (CDN, 主源) — 平台 ToS 不阻挡用户浏览
|
|
13
|
+
* Tier 2 卖家自己节点 seller_node_url — 平台失效时仍可拉
|
|
14
|
+
* Tier 3 WebAZ 协议索引 (本表) — hash + 签名 + ownership 验证
|
|
15
|
+
*
|
|
16
|
+
* 反 scraping 立场:服务器从不主动 fetch 外平台。所有 canonical
|
|
17
|
+
* 提取由客户端 / verifier 节点完成,本表只接受**提交 + 签名**。
|
|
18
|
+
*
|
|
19
|
+
* Ownership 验证流程:
|
|
20
|
+
* 1. 卖家想绑外平台账号 → 服务器发 ownership_token = "WAZ-V-{8hex}"
|
|
21
|
+
* 2. 卖家在外平台 listing 描述 / 店铺简介里嵌入 token
|
|
22
|
+
* 3. 任一用户 (verifier 角色或他人) 打开外链确认 token 存在 → 提交 verify
|
|
23
|
+
* 4. 2+ 独立 verifier 一致确认 → ownership_verified = 'community'
|
|
24
|
+
* (卖家自报为 'self_claimed',可疑度较高,需后续社区验证)
|
|
25
|
+
*/
|
|
26
|
+
import crypto from 'crypto';
|
|
27
|
+
import { generateId } from '../../layer0-foundation/L0-1-database/schema.js';
|
|
28
|
+
export function initExternalAnchorSchema(db) {
|
|
29
|
+
db.exec(`
|
|
30
|
+
CREATE TABLE IF NOT EXISTS external_anchors (
|
|
31
|
+
id TEXT PRIMARY KEY,
|
|
32
|
+
seller_id TEXT NOT NULL,
|
|
33
|
+
product_id TEXT, -- NULL = 店铺级 anchor,非具体商品
|
|
34
|
+
platform TEXT NOT NULL, -- 'taobao' | 'jd' | 'douyin' | 'amazon' | ...
|
|
35
|
+
external_url TEXT NOT NULL,
|
|
36
|
+
canonical_json TEXT NOT NULL, -- 客户端提取的 canonical JSON
|
|
37
|
+
content_hash TEXT NOT NULL, -- sha256(canonical_json)
|
|
38
|
+
signature TEXT NOT NULL, -- HMAC-SHA256(seller_api_key, canonical_json)
|
|
39
|
+
seller_node_url TEXT, -- 卖家自有节点兜底 URL(可选)
|
|
40
|
+
ownership_token TEXT, -- 当前生效的 ownership 验证 token
|
|
41
|
+
ownership_token_at TEXT, -- token 发放时间
|
|
42
|
+
ownership_verified TEXT DEFAULT 'none', -- OwnershipLevel
|
|
43
|
+
ownership_verified_at TEXT,
|
|
44
|
+
verify_count INTEGER DEFAULT 0, -- community 验证累积票数
|
|
45
|
+
last_verified_at TEXT,
|
|
46
|
+
revoked INTEGER DEFAULT 0,
|
|
47
|
+
revoked_at TEXT,
|
|
48
|
+
revoked_reason TEXT,
|
|
49
|
+
posted_at TEXT DEFAULT (datetime('now'))
|
|
50
|
+
);
|
|
51
|
+
CREATE TABLE IF NOT EXISTS external_anchor_verifications (
|
|
52
|
+
id TEXT PRIMARY KEY,
|
|
53
|
+
anchor_id TEXT NOT NULL,
|
|
54
|
+
verifier_id TEXT NOT NULL,
|
|
55
|
+
verifier_role TEXT NOT NULL,
|
|
56
|
+
submitted_canonical_json TEXT NOT NULL,
|
|
57
|
+
submitted_content_hash TEXT NOT NULL,
|
|
58
|
+
content_matches INTEGER NOT NULL, -- 0/1
|
|
59
|
+
token_found INTEGER NOT NULL, -- 0/1(如果 anchor 在做 ownership 验证)
|
|
60
|
+
verified_at TEXT DEFAULT (datetime('now')),
|
|
61
|
+
notes TEXT,
|
|
62
|
+
UNIQUE(anchor_id, verifier_id) -- 一人一锚一票
|
|
63
|
+
);
|
|
64
|
+
`);
|
|
65
|
+
try {
|
|
66
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_anchor_seller ON external_anchors(seller_id, revoked)');
|
|
67
|
+
}
|
|
68
|
+
catch { }
|
|
69
|
+
try {
|
|
70
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_anchor_product ON external_anchors(product_id, revoked)');
|
|
71
|
+
}
|
|
72
|
+
catch { }
|
|
73
|
+
try {
|
|
74
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_anchor_platform ON external_anchors(platform, revoked)');
|
|
75
|
+
}
|
|
76
|
+
catch { }
|
|
77
|
+
try {
|
|
78
|
+
db.exec('CREATE INDEX IF NOT EXISTS idx_anchor_verif ON external_anchor_verifications(anchor_id, verified_at DESC)');
|
|
79
|
+
}
|
|
80
|
+
catch { }
|
|
81
|
+
// #6 验证激励:seller 创建 anchor 时可付 verification_fee → community 升级时按正确投票均分给 verifier
|
|
82
|
+
for (const stmt of [
|
|
83
|
+
'ALTER TABLE external_anchors ADD COLUMN verification_fee REAL DEFAULT 0',
|
|
84
|
+
'ALTER TABLE external_anchors ADD COLUMN fee_paid_out INTEGER DEFAULT 0',
|
|
85
|
+
'ALTER TABLE external_anchor_verifications ADD COLUMN reward_amount REAL DEFAULT 0',
|
|
86
|
+
]) {
|
|
87
|
+
try {
|
|
88
|
+
db.exec(stmt);
|
|
89
|
+
}
|
|
90
|
+
catch { /* 已存在 */ }
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// 推荐 verification_fee 默认值(前端给的提示,实际由 seller 决定,可为 0 = 不开启 community 验证)
|
|
94
|
+
export const ANCHOR_VERIFICATION_FEE_RECOMMENDED = 2.0;
|
|
95
|
+
function canonicalSerialize(obj) {
|
|
96
|
+
if (obj === null || obj === undefined)
|
|
97
|
+
return JSON.stringify(obj);
|
|
98
|
+
if (Array.isArray(obj))
|
|
99
|
+
return '[' + obj.map(canonicalSerialize).join(',') + ']';
|
|
100
|
+
if (typeof obj === 'object') {
|
|
101
|
+
const keys = Object.keys(obj).sort();
|
|
102
|
+
return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalSerialize(obj[k])).join(',') + '}';
|
|
103
|
+
}
|
|
104
|
+
return JSON.stringify(obj);
|
|
105
|
+
}
|
|
106
|
+
export function sha256Hex(input) {
|
|
107
|
+
return crypto.createHash('sha256').update(input).digest('hex');
|
|
108
|
+
}
|
|
109
|
+
const VALID_PLATFORMS = new Set([
|
|
110
|
+
'taobao', 'tmall', 'jd', 'pdd', 'douyin', '1688',
|
|
111
|
+
'xiaohongshu', 'weidian', 'shopify',
|
|
112
|
+
'amazon', 'shopee', 'lazada', 'aliexpress',
|
|
113
|
+
'instagram', 'tiktok', 'youtube',
|
|
114
|
+
'other',
|
|
115
|
+
]);
|
|
116
|
+
export function createAnchor(db, args) {
|
|
117
|
+
if (!VALID_PLATFORMS.has(args.platform))
|
|
118
|
+
throw new Error('anchor_unknown_platform:' + args.platform);
|
|
119
|
+
if (!/^https?:\/\//i.test(args.externalUrl))
|
|
120
|
+
throw new Error('anchor_invalid_url');
|
|
121
|
+
if (!args.canonical || typeof args.canonical !== 'object')
|
|
122
|
+
throw new Error('anchor_invalid_canonical');
|
|
123
|
+
const seller = db.prepare('SELECT api_key, role FROM users WHERE id = ?').get(args.sellerId);
|
|
124
|
+
if (!seller)
|
|
125
|
+
throw new Error('anchor_seller_not_found');
|
|
126
|
+
if (seller.role !== 'seller')
|
|
127
|
+
throw new Error('anchor_only_seller_can_anchor');
|
|
128
|
+
// 修复 ultrareview bug_003:product_id 给了就必须验卖家是商品归属人,
|
|
129
|
+
// 否则任何卖家能往别人的 product 页"插"自己的外置存证(impersonation)
|
|
130
|
+
if (args.productId) {
|
|
131
|
+
const p = db.prepare('SELECT seller_id FROM products WHERE id = ?').get(args.productId);
|
|
132
|
+
if (!p)
|
|
133
|
+
throw new Error('anchor_product_not_found');
|
|
134
|
+
if (p.seller_id !== args.sellerId)
|
|
135
|
+
throw new Error('anchor_not_product_owner');
|
|
136
|
+
}
|
|
137
|
+
// 修复 ultrareview bug_013:之前同 URL 重新声明会洗掉之前的 disputed verdict —
|
|
138
|
+
// 拒绝在 disputed 状态下重声明,必须先 revokeAnchor 走显式撤销路径
|
|
139
|
+
const prior = db.prepare('SELECT id, ownership_verified FROM external_anchors WHERE seller_id = ? AND external_url = ? AND revoked = 0').get(args.sellerId, args.externalUrl);
|
|
140
|
+
if (prior?.ownership_verified === 'disputed') {
|
|
141
|
+
throw new Error('anchor_disputed_must_clear_first');
|
|
142
|
+
}
|
|
143
|
+
// canonical_json 严格 key 排序,让 sender / verifier 算出同样 hash
|
|
144
|
+
const canonJson = canonicalSerialize(args.canonical);
|
|
145
|
+
if (canonJson.length > 64 * 1024)
|
|
146
|
+
throw new Error('anchor_canonical_too_large');
|
|
147
|
+
const contentHash = sha256Hex(canonJson);
|
|
148
|
+
const signature = crypto.createHmac('sha256', seller.api_key).update(canonJson).digest('hex');
|
|
149
|
+
// 同卖家 + 同 URL 视为重新声明(先撤旧再发新)— 仅在非 disputed 时允许
|
|
150
|
+
db.prepare('UPDATE external_anchors SET revoked = 1, revoked_at = datetime(\'now\'), revoked_reason = ? WHERE seller_id = ? AND external_url = ? AND revoked = 0').run('superseded', args.sellerId, args.externalUrl);
|
|
151
|
+
// #6 验证激励费 — 可选,从 seller 钱包扣款锁入 anchor 的奖励池
|
|
152
|
+
const fee = Math.max(0, Math.round(Number(args.verificationFee || 0) * 100) / 100);
|
|
153
|
+
if (fee > 0) {
|
|
154
|
+
const w = db.prepare('SELECT balance FROM wallets WHERE user_id = ?').get(args.sellerId);
|
|
155
|
+
if (!w || w.balance < fee)
|
|
156
|
+
throw new Error('anchor_insufficient_balance_for_fee');
|
|
157
|
+
db.prepare('UPDATE wallets SET balance = balance - ? WHERE user_id = ?').run(fee, args.sellerId);
|
|
158
|
+
}
|
|
159
|
+
const id = generateId('xa');
|
|
160
|
+
db.prepare(`
|
|
161
|
+
INSERT INTO external_anchors
|
|
162
|
+
(id, seller_id, product_id, platform, external_url, canonical_json, content_hash, signature, seller_node_url, verification_fee)
|
|
163
|
+
VALUES (?,?,?,?,?,?,?,?,?,?)
|
|
164
|
+
`).run(id, args.sellerId, args.productId || null, args.platform, args.externalUrl, canonJson, contentHash, signature, args.sellerNodeUrl || null, fee);
|
|
165
|
+
return { id, content_hash: contentHash, signature, verification_fee: fee };
|
|
166
|
+
}
|
|
167
|
+
export function verifyAnchorSignature(db, anchorId) {
|
|
168
|
+
const r = db.prepare('SELECT seller_id, canonical_json, content_hash, signature FROM external_anchors WHERE id = ?').get(anchorId);
|
|
169
|
+
if (!r)
|
|
170
|
+
return { ok: false, reason: 'not_found' };
|
|
171
|
+
const reHash = sha256Hex(r.canonical_json);
|
|
172
|
+
if (reHash !== r.content_hash)
|
|
173
|
+
return { ok: false, reason: 'content_hash_mismatch' };
|
|
174
|
+
const seller = db.prepare('SELECT api_key FROM users WHERE id = ?').get(r.seller_id);
|
|
175
|
+
if (!seller)
|
|
176
|
+
return { ok: false, reason: 'seller_not_found' };
|
|
177
|
+
const sig = crypto.createHmac('sha256', seller.api_key).update(r.canonical_json).digest('hex');
|
|
178
|
+
return sig === r.signature ? { ok: true } : { ok: false, reason: 'signature_mismatch' };
|
|
179
|
+
}
|
|
180
|
+
export function revokeAnchor(db, anchorId, sellerId, reason) {
|
|
181
|
+
const r = db.prepare('SELECT seller_id, revoked FROM external_anchors WHERE id = ?').get(anchorId);
|
|
182
|
+
if (!r)
|
|
183
|
+
return { ok: false, reason: 'not_found' };
|
|
184
|
+
if (r.seller_id !== sellerId)
|
|
185
|
+
return { ok: false, reason: 'not_owner' };
|
|
186
|
+
if (r.revoked)
|
|
187
|
+
return { ok: false, reason: 'already_revoked' };
|
|
188
|
+
db.prepare('UPDATE external_anchors SET revoked = 1, revoked_at = datetime(\'now\'), revoked_reason = ? WHERE id = ?').run(reason.slice(0, 200), anchorId);
|
|
189
|
+
return { ok: true };
|
|
190
|
+
}
|
|
191
|
+
// 服务器发 ownership token — 卖家把它嵌到外平台 listing 描述里证明自己是 url 主人
|
|
192
|
+
export function issueOwnershipToken(db, anchorId, sellerId) {
|
|
193
|
+
const r = db.prepare('SELECT seller_id, revoked FROM external_anchors WHERE id = ?').get(anchorId);
|
|
194
|
+
if (!r)
|
|
195
|
+
return { ok: false, reason: 'not_found' };
|
|
196
|
+
if (r.seller_id !== sellerId)
|
|
197
|
+
return { ok: false, reason: 'not_owner' };
|
|
198
|
+
if (r.revoked)
|
|
199
|
+
return { ok: false, reason: 'revoked' };
|
|
200
|
+
const token = 'WAZ-V-' + crypto.randomBytes(4).toString('hex').toUpperCase();
|
|
201
|
+
db.prepare(`UPDATE external_anchors SET ownership_token = ?, ownership_token_at = datetime('now'), ownership_verified = 'self_claimed' WHERE id = ?`).run(token, anchorId);
|
|
202
|
+
return { ok: true, token };
|
|
203
|
+
}
|
|
204
|
+
// 任一用户作为 verifier 提交独立 canonical + ownership token 检查
|
|
205
|
+
export function submitVerification(db, args) {
|
|
206
|
+
const anchor = db.prepare('SELECT * FROM external_anchors WHERE id = ?').get(args.anchorId);
|
|
207
|
+
if (!anchor)
|
|
208
|
+
return { ok: false, reason: 'not_found' };
|
|
209
|
+
if (anchor.revoked)
|
|
210
|
+
return { ok: false, reason: 'revoked' };
|
|
211
|
+
if (anchor.seller_id === args.verifierId)
|
|
212
|
+
return { ok: false, reason: 'self_verify_disallowed' };
|
|
213
|
+
// 重复提交检查(UNIQUE 约束兜底)
|
|
214
|
+
const dup = db.prepare('SELECT id FROM external_anchor_verifications WHERE anchor_id = ? AND verifier_id = ?').get(args.anchorId, args.verifierId);
|
|
215
|
+
if (dup)
|
|
216
|
+
return { ok: false, reason: 'already_verified' };
|
|
217
|
+
const subCanon = canonicalSerialize(args.submittedCanonical);
|
|
218
|
+
const subHash = sha256Hex(subCanon);
|
|
219
|
+
const matches = subHash === anchor.content_hash;
|
|
220
|
+
db.prepare(`
|
|
221
|
+
INSERT INTO external_anchor_verifications
|
|
222
|
+
(id, anchor_id, verifier_id, verifier_role, submitted_canonical_json, submitted_content_hash, content_matches, token_found, notes)
|
|
223
|
+
VALUES (?,?,?,?,?,?,?,?,?)
|
|
224
|
+
`).run(generateId('xav'), args.anchorId, args.verifierId, args.verifierRole, subCanon, subHash, matches ? 1 : 0, args.tokenFoundInExternal ? 1 : 0, (args.notes || '').slice(0, 500));
|
|
225
|
+
// 更新 anchor 累积票数 + 自动升级 ownership 等级
|
|
226
|
+
const stats = db.prepare(`
|
|
227
|
+
SELECT COUNT(*) as total, SUM(content_matches) as match_cnt, SUM(token_found) as tok_cnt
|
|
228
|
+
FROM external_anchor_verifications WHERE anchor_id = ?
|
|
229
|
+
`).get(args.anchorId);
|
|
230
|
+
// 多数表决 + Sybil 防护(迭代修复 ultrareview bug_004 + bug_008)
|
|
231
|
+
// 之前 community 只要 2 个 token 票 + 67% match — 2 个 sockpuppet 账号就能伪造 badge
|
|
232
|
+
// 现在:community 升级**必须** 来自 verifier / arbitrator 角色,且 ≥3 个独立角色投票
|
|
233
|
+
// disputed 仍由任一身份 ≥2 票触发(社区警示门槛低)
|
|
234
|
+
// community ← verifier_role_match_cnt ≥ 3 AND token_found ≥ 2 AND ratio ≥ 67%
|
|
235
|
+
// disputed ← mismatch ≥ 2 AND ratio < 67%
|
|
236
|
+
// 其他 ← self_claimed
|
|
237
|
+
// 受信角色票统计(只算 verifier / arbitrator,普通 buyer/seller 不算 community 升级票)
|
|
238
|
+
const trustedStats = db.prepare(`
|
|
239
|
+
SELECT COUNT(*) as total, SUM(content_matches) as match_cnt, SUM(token_found) as tok_cnt
|
|
240
|
+
FROM external_anchor_verifications
|
|
241
|
+
WHERE anchor_id = ? AND verifier_role IN ('verifier', 'arbitrator')
|
|
242
|
+
`).get(args.anchorId);
|
|
243
|
+
const mismatchCnt = stats.total - stats.match_cnt;
|
|
244
|
+
const matchRatio = stats.total > 0 ? stats.match_cnt / stats.total : 0;
|
|
245
|
+
let newLevel = anchor.ownership_verified || 'none';
|
|
246
|
+
if (mismatchCnt >= 2 && matchRatio < 0.67) {
|
|
247
|
+
newLevel = 'disputed';
|
|
248
|
+
}
|
|
249
|
+
else if (trustedStats.match_cnt >= 3 && trustedStats.tok_cnt >= 2 && matchRatio >= 0.67 && anchor.ownership_token) {
|
|
250
|
+
newLevel = 'community';
|
|
251
|
+
}
|
|
252
|
+
else if (anchor.ownership_token) {
|
|
253
|
+
newLevel = anchor.ownership_verified === 'community' ? 'community' : 'self_claimed';
|
|
254
|
+
}
|
|
255
|
+
const wasCommunity = anchor.ownership_verified === 'community';
|
|
256
|
+
db.prepare(`UPDATE external_anchors SET
|
|
257
|
+
verify_count = ?, last_verified_at = datetime('now'),
|
|
258
|
+
ownership_verified = ?,
|
|
259
|
+
ownership_verified_at = CASE WHEN ? = 'community' AND ownership_verified != 'community' THEN datetime('now') ELSE ownership_verified_at END
|
|
260
|
+
WHERE id = ?`).run(stats.total, newLevel, newLevel, args.anchorId);
|
|
261
|
+
// #6 验证激励:刚升级到 community → 分发 verification_fee 给所有 matching verifier/arbitrator
|
|
262
|
+
let rewardPaid = 0;
|
|
263
|
+
if (newLevel === 'community' && !wasCommunity) {
|
|
264
|
+
rewardPaid = distributeAnchorRewards(db, args.anchorId);
|
|
265
|
+
}
|
|
266
|
+
return { ok: true, matches, token_found: args.tokenFoundInExternal, ownership_level: newLevel, reward_paid: rewardPaid };
|
|
267
|
+
}
|
|
268
|
+
// #6 验证奖励分发 — 在 anchor 首次升 community 时调用
|
|
269
|
+
// 把 verification_fee 均分给所有 content_matches=1 的 verifier/arbitrator 角色投票者
|
|
270
|
+
// 幂等:fee_paid_out=1 后再调用返回 0
|
|
271
|
+
// 调用方:通常由 submitVerification 自动触发;也可被管理员手动重发(先重置 fee_paid_out)
|
|
272
|
+
export function distributeAnchorRewards(db, anchorId) {
|
|
273
|
+
const a = db.prepare(`SELECT verification_fee, fee_paid_out, ownership_verified FROM external_anchors WHERE id = ?`).get(anchorId);
|
|
274
|
+
if (!a)
|
|
275
|
+
return 0;
|
|
276
|
+
if (a.fee_paid_out)
|
|
277
|
+
return 0;
|
|
278
|
+
if (!a.verification_fee || a.verification_fee <= 0) {
|
|
279
|
+
// 没费可分,标 paid_out 防重复扫描
|
|
280
|
+
db.prepare(`UPDATE external_anchors SET fee_paid_out = 1 WHERE id = ?`).run(anchorId);
|
|
281
|
+
return 0;
|
|
282
|
+
}
|
|
283
|
+
if (a.ownership_verified !== 'community')
|
|
284
|
+
return 0; // 只在 community 才发放
|
|
285
|
+
// 找正确投票的可信角色(与 community 升级条件一致)
|
|
286
|
+
const winners = db.prepare(`
|
|
287
|
+
SELECT id, verifier_id FROM external_anchor_verifications
|
|
288
|
+
WHERE anchor_id = ? AND verifier_role IN ('verifier', 'arbitrator') AND content_matches = 1
|
|
289
|
+
ORDER BY verified_at ASC
|
|
290
|
+
`).all(anchorId);
|
|
291
|
+
if (winners.length === 0) {
|
|
292
|
+
db.prepare(`UPDATE external_anchors SET fee_paid_out = 1 WHERE id = ?`).run(anchorId);
|
|
293
|
+
return 0;
|
|
294
|
+
}
|
|
295
|
+
const share = Math.round((a.verification_fee / winners.length) * 100) / 100;
|
|
296
|
+
let actualPaid = 0;
|
|
297
|
+
db.transaction(() => {
|
|
298
|
+
for (const w of winners) {
|
|
299
|
+
// 确保 wallet 存在
|
|
300
|
+
db.prepare(`INSERT OR IGNORE INTO wallets (user_id, balance) VALUES (?, 0)`).run(w.verifier_id);
|
|
301
|
+
db.prepare(`UPDATE wallets SET balance = balance + ?, earned = earned + ? WHERE user_id = ?`).run(share, share, w.verifier_id);
|
|
302
|
+
db.prepare(`UPDATE external_anchor_verifications SET reward_amount = ? WHERE id = ?`).run(share, w.id);
|
|
303
|
+
actualPaid += share;
|
|
304
|
+
}
|
|
305
|
+
// 浮点精度:实际 paid 可能比 fee 略少 (e.g. 5/3=1.66×3=4.98),差额留 anchor 不发
|
|
306
|
+
db.prepare(`UPDATE external_anchors SET fee_paid_out = 1 WHERE id = ?`).run(anchorId);
|
|
307
|
+
})();
|
|
308
|
+
return Math.round(actualPaid * 100) / 100;
|
|
309
|
+
}
|
|
310
|
+
export function getAnchor(db, anchorId) {
|
|
311
|
+
const row = db.prepare('SELECT * FROM external_anchors WHERE id = ?').get(anchorId);
|
|
312
|
+
if (!row)
|
|
313
|
+
return null;
|
|
314
|
+
const verifs = db.prepare('SELECT id, verifier_id, verifier_role, content_matches, token_found, verified_at FROM external_anchor_verifications WHERE anchor_id = ? ORDER BY verified_at DESC LIMIT 20').all(anchorId);
|
|
315
|
+
return { ...row, verifications: verifs };
|
|
316
|
+
}
|
|
317
|
+
export function listAnchorsByProduct(db, productId) {
|
|
318
|
+
return db.prepare(`SELECT id, seller_id, platform, external_url, content_hash, ownership_verified, verify_count, seller_node_url, posted_at, revoked
|
|
319
|
+
FROM external_anchors WHERE product_id = ? AND revoked = 0 ORDER BY posted_at DESC`).all(productId);
|
|
320
|
+
}
|
|
321
|
+
export function listAnchorsBySeller(db, sellerId) {
|
|
322
|
+
return db.prepare(`SELECT id, product_id, platform, external_url, content_hash, ownership_verified, verify_count, seller_node_url, posted_at, revoked
|
|
323
|
+
FROM external_anchors WHERE seller_id = ? ORDER BY revoked ASC, posted_at DESC LIMIT 200`).all(sellerId);
|
|
324
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
const HALF_LIFE_DAYS = 30; // 衰减半衰期(默认 30 天)
|
|
2
|
+
// endpoint+method → 行为桶
|
|
3
|
+
function bucketOf(endpoint, method) {
|
|
4
|
+
const e = String(endpoint || '');
|
|
5
|
+
const m = String(method || 'GET').toUpperCase();
|
|
6
|
+
if (/vote|arbitrate|governance|protocol-params|claim-tasks|disputes\/[^/]+\/(arbitrate|respond)/.test(e))
|
|
7
|
+
return 'govern';
|
|
8
|
+
if (m === 'GET')
|
|
9
|
+
return 'query';
|
|
10
|
+
// 非 GET 的业务写 → 交易类
|
|
11
|
+
if (/orders|products|bids|rfqs|wallet|skill-market|secondhand|auction|p2p|trial|claim/.test(e))
|
|
12
|
+
return 'transact';
|
|
13
|
+
return 'transact'; // 其余写操作也归交易类(保守)
|
|
14
|
+
}
|
|
15
|
+
const round2 = (n) => Math.round(n * 100) / 100;
|
|
16
|
+
const clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, n));
|
|
17
|
+
/**
|
|
18
|
+
* 纯只读派生。fingerprintFn 由上层注入(用协议 secret hash 监护人 id)。
|
|
19
|
+
* 所有可选表/列查询用 try 包裹,缺失时退化为 0,绝不抛。
|
|
20
|
+
*/
|
|
21
|
+
export function computeAgentPassport(db, apiKey, ownerId, fingerprintFn) {
|
|
22
|
+
// ── 行为画像 + 调用量(30 天)──
|
|
23
|
+
const rows = (() => {
|
|
24
|
+
try {
|
|
25
|
+
return db.prepare(`SELECT endpoint, method FROM agent_call_log WHERE api_key = ? AND created_at > datetime('now','-30 days')`).all(apiKey);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
30
|
+
})();
|
|
31
|
+
const counts = { query: 0, transact: 0, govern: 0 };
|
|
32
|
+
for (const r of rows)
|
|
33
|
+
counts[bucketOf(r.endpoint, r.method)]++;
|
|
34
|
+
const calls30d = rows.length;
|
|
35
|
+
const denom = calls30d || 1;
|
|
36
|
+
const behavior_profile = {
|
|
37
|
+
query: round2(counts.query / denom),
|
|
38
|
+
transact: round2(counts.transact / denom),
|
|
39
|
+
govern: round2(counts.govern / denom),
|
|
40
|
+
};
|
|
41
|
+
const governance_calls_30d = counts.govern;
|
|
42
|
+
// ── 风险分 ──
|
|
43
|
+
// 1) 429 高频(主攻击信号)+ 其余 4xx/5xx,按事件年龄做 30d 半衰期衰减(90d 窗口)
|
|
44
|
+
let rlWeighted = 0;
|
|
45
|
+
let errWeighted = 0;
|
|
46
|
+
try {
|
|
47
|
+
const bad = db.prepare(`
|
|
48
|
+
SELECT status_code AS sc, (julianday('now') - julianday(created_at)) AS age_days
|
|
49
|
+
FROM agent_call_log
|
|
50
|
+
WHERE api_key = ? AND status_code >= 400 AND created_at > datetime('now','-90 days')
|
|
51
|
+
`).all(apiKey);
|
|
52
|
+
for (const b of bad) {
|
|
53
|
+
const w = Math.pow(0.5, Math.max(0, b.age_days) / HALF_LIFE_DAYS);
|
|
54
|
+
if (Number(b.sc) === 429)
|
|
55
|
+
rlWeighted += w;
|
|
56
|
+
else
|
|
57
|
+
errWeighted += w;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch { /* no call log */ }
|
|
61
|
+
// 2) 纠纷败诉(监护人级,结构罚)
|
|
62
|
+
let disputeLoss = 0;
|
|
63
|
+
try {
|
|
64
|
+
disputeLoss = db.prepare(`SELECT COUNT(*) AS n FROM disputes WHERE defendant_id = ? AND ruling_type IN ('refund_buyer','partial_refund')`).get(ownerId).n;
|
|
65
|
+
}
|
|
66
|
+
catch { /* table/col absent */ }
|
|
67
|
+
// 3) sybil 簇:同注册 IP 的他户数(结构罚)。注册 IP 在 registration_audit_log。
|
|
68
|
+
let sybilExcess = 0;
|
|
69
|
+
try {
|
|
70
|
+
const me = db.prepare(`SELECT ip_hash FROM registration_audit_log WHERE user_id = ? ORDER BY id DESC LIMIT 1`).get(ownerId);
|
|
71
|
+
if (me?.ip_hash) {
|
|
72
|
+
const sz = db.prepare(`SELECT COUNT(DISTINCT user_id) AS n FROM registration_audit_log WHERE ip_hash = ?`).get(me.ip_hash).n;
|
|
73
|
+
sybilExcess = Math.max(0, sz - 3); // ≤3 视为正常(家庭/NAT)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch { /* table absent */ }
|
|
77
|
+
const risk_score = clamp(Math.round(Math.min(40, rlWeighted * 4) + // 429 高频 → 最多 40
|
|
78
|
+
Math.min(15, errWeighted * 1.5) + // 其余错误 → 最多 15
|
|
79
|
+
Math.min(25, disputeLoss * 8) + // 纠纷败诉 → 最多 25
|
|
80
|
+
Math.min(20, sybilExcess * 5) // sybil 簇 → 最多 20
|
|
81
|
+
), 0, 100);
|
|
82
|
+
// ── 参与深度 ──
|
|
83
|
+
let engagement_depth;
|
|
84
|
+
if (governance_calls_30d > 0 || calls30d >= 1000)
|
|
85
|
+
engagement_depth = 'profound';
|
|
86
|
+
else if (calls30d >= 100 || counts.transact >= 20)
|
|
87
|
+
engagement_depth = 'deep';
|
|
88
|
+
else if (calls30d >= 10 || counts.transact >= 1)
|
|
89
|
+
engagement_depth = 'medium';
|
|
90
|
+
else
|
|
91
|
+
engagement_depth = 'shallow';
|
|
92
|
+
return {
|
|
93
|
+
risk_score,
|
|
94
|
+
engagement_depth,
|
|
95
|
+
behavior_profile,
|
|
96
|
+
custodian_fingerprint: fingerprintFn(ownerId),
|
|
97
|
+
calls_30d: calls30d,
|
|
98
|
+
governance_calls_30d,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -31,7 +31,7 @@ const RULES = {
|
|
|
31
31
|
'created→paid': {
|
|
32
32
|
recipients: ['seller'],
|
|
33
33
|
title: '🛍️ 新订单',
|
|
34
|
-
body: ctx => `${ctx.buyerName} 下单了「${ctx.productTitle}」,金额 ${ctx.totalAmount}
|
|
34
|
+
body: ctx => `${ctx.buyerName} 下单了「${ctx.productTitle}」,金额 ${ctx.totalAmount} WAZ。请在 24h 内接单,否则自动退款。`,
|
|
35
35
|
},
|
|
36
36
|
'paid→accepted': {
|
|
37
37
|
recipients: ['buyer'],
|
|
@@ -41,7 +41,7 @@ const RULES = {
|
|
|
41
41
|
'paid→cancelled': {
|
|
42
42
|
recipients: ['buyer'],
|
|
43
43
|
title: '❌ 订单已取消',
|
|
44
|
-
body: ctx => `订单「${ctx.productTitle}」已取消,${ctx.totalAmount}
|
|
44
|
+
body: ctx => `订单「${ctx.productTitle}」已取消,${ctx.totalAmount} WAZ 将原路退回。`,
|
|
45
45
|
},
|
|
46
46
|
'accepted→shipped': {
|
|
47
47
|
recipients: ['buyer'],
|
|
@@ -53,6 +53,14 @@ const RULES = {
|
|
|
53
53
|
title: '🚚 物流已揽收',
|
|
54
54
|
body: ctx => `包裹已由${ctx.logisticsName ?? '物流方'}揽收,正在运输中。`,
|
|
55
55
|
},
|
|
56
|
+
'picked_up→in_transit': {
|
|
57
|
+
recipients: ['buyer'],
|
|
58
|
+
title: '🚛 包裹运输中',
|
|
59
|
+
body: ctx => `你的「${ctx.productTitle}」正在运输途中。`,
|
|
60
|
+
},
|
|
61
|
+
// 注:曾有 'accepted→cancelled' 通知规则 — 但 VALID_TRANSITIONS 不允许 accepted 直接到
|
|
62
|
+
// cancelled(只能走 disputed 或 fault_seller)。该规则永不触发,已删除。
|
|
63
|
+
// 若以后开"卖家接单后主动取消"通道,需先在 L0-2 状态机加 transition,再恢复此规则。
|
|
56
64
|
'in_transit→delivered': {
|
|
57
65
|
recipients: ['buyer'],
|
|
58
66
|
title: '📬 包裹已投递',
|
|
@@ -61,7 +69,7 @@ const RULES = {
|
|
|
61
69
|
'delivered→confirmed': {
|
|
62
70
|
recipients: ['seller'],
|
|
63
71
|
title: '💰 买家确认收货',
|
|
64
|
-
body: ctx => `${ctx.buyerName} 已确认收货,${ctx.totalAmount}
|
|
72
|
+
body: ctx => `${ctx.buyerName} 已确认收货,${ctx.totalAmount} WAZ 结算中。`,
|
|
65
73
|
},
|
|
66
74
|
'confirmed→completed': {
|
|
67
75
|
recipients: ['seller'],
|
|
@@ -101,12 +109,12 @@ const RULES = {
|
|
|
101
109
|
'disputed→cancelled': {
|
|
102
110
|
recipients: ['buyer', 'seller'],
|
|
103
111
|
title: '⚖️ 争议裁定:退款买家',
|
|
104
|
-
body: ctx => `订单「${ctx.productTitle}」争议已裁定,${ctx.totalAmount}
|
|
112
|
+
body: ctx => `订单「${ctx.productTitle}」争议已裁定,${ctx.totalAmount} WAZ 已退回买家。`,
|
|
105
113
|
},
|
|
106
114
|
'paid→fault_seller': {
|
|
107
115
|
recipients: ['buyer', 'seller'],
|
|
108
116
|
title: '⏰ 卖家超时违约',
|
|
109
|
-
body: ctx => `卖家超时未接单,订单已自动取消,${ctx.totalAmount}
|
|
117
|
+
body: ctx => `卖家超时未接单,订单已自动取消,${ctx.totalAmount} WAZ 退款处理中。`,
|
|
110
118
|
},
|
|
111
119
|
'accepted→fault_seller': {
|
|
112
120
|
recipients: ['buyer', 'seller'],
|
|
@@ -215,3 +223,62 @@ export function markRead(db, userId, notifId) {
|
|
|
215
223
|
db.prepare('UPDATE notifications SET read = 1 WHERE user_id = ?').run(userId);
|
|
216
224
|
}
|
|
217
225
|
}
|
|
226
|
+
// ─── 截止时间临近提醒 — 防"超时被判违约才知道"────────────────
|
|
227
|
+
// 每次扫描幂等:每个 (order_id, reminder_type) 只发一次
|
|
228
|
+
// reminder_type 形如 'reminder:accept_6h' / 'reminder:ship_12h' / 'reminder:confirm_24h'
|
|
229
|
+
const REMINDER_THRESHOLDS = [
|
|
230
|
+
// 卖家:付款后 6h 内未接单 → 提醒卖家
|
|
231
|
+
{ status: 'paid', deadlineCol: 'accept_deadline', hoursBefore: 6,
|
|
232
|
+
recipientRole: 'seller', type: 'reminder:accept_6h',
|
|
233
|
+
title: '⏰ 还有 6h 接单截止',
|
|
234
|
+
body: ctx => `订单「${ctx.productTitle}」还有 6 小时未接单将被判违约(扣信誉 + 自动退款)。` },
|
|
235
|
+
// 卖家:发货截止前 12h 提醒
|
|
236
|
+
{ status: 'accepted', deadlineCol: 'ship_deadline', hoursBefore: 12,
|
|
237
|
+
recipientRole: 'seller', type: 'reminder:ship_12h',
|
|
238
|
+
title: '⏰ 还有 12h 发货截止',
|
|
239
|
+
body: ctx => `订单「${ctx.productTitle}」还有 12 小时未发货将被判违约,请抓紧时间。` },
|
|
240
|
+
// 物流:揽收截止前 6h
|
|
241
|
+
{ status: 'shipped', deadlineCol: 'pickup_deadline', hoursBefore: 6,
|
|
242
|
+
recipientRole: 'logistics', type: 'reminder:pickup_6h',
|
|
243
|
+
title: '⏰ 还有 6h 揽收截止',
|
|
244
|
+
body: ctx => `订单「${ctx.productTitle}」还有 6 小时未揽收将被判违约。` },
|
|
245
|
+
// 物流:投递截止前 12h
|
|
246
|
+
{ status: 'in_transit', deadlineCol: 'delivery_deadline', hoursBefore: 12,
|
|
247
|
+
recipientRole: 'logistics', type: 'reminder:delivery_12h',
|
|
248
|
+
title: '⏰ 还有 12h 投递截止',
|
|
249
|
+
body: ctx => `订单「${ctx.productTitle}」还有 12 小时未投递将被判违约。` },
|
|
250
|
+
// 买家:确认收货截止前 24h
|
|
251
|
+
{ status: 'delivered', deadlineCol: 'confirm_deadline', hoursBefore: 24,
|
|
252
|
+
recipientRole: 'buyer', type: 'reminder:confirm_24h',
|
|
253
|
+
title: '⏰ 还有 24h 自动确认',
|
|
254
|
+
body: ctx => `「${ctx.productTitle}」已送达,24 小时内未确认将自动确认收货 + 释放资金,如有问题请发起争议。` },
|
|
255
|
+
];
|
|
256
|
+
export function scanDeadlineReminders(db) {
|
|
257
|
+
const details = [];
|
|
258
|
+
const nowMs = Date.now();
|
|
259
|
+
for (const r of REMINDER_THRESHOLDS) {
|
|
260
|
+
// 候选订单:当前状态 + deadline 在 (now, now+hoursBefore) 之间
|
|
261
|
+
const targetMs = nowMs + r.hoursBefore * 3600_000;
|
|
262
|
+
// datetime() wrap on LHS — addHours 用 ISO 'T' 格式存,SQLite datetime('now') 返空格格式,
|
|
263
|
+
// 同日 prefix 相同时 lex 比较 'T'(0x54) > ' '(0x20) 会让所有 same-day deadline 跳出窗口
|
|
264
|
+
const sql = `SELECT id, ${r.deadlineCol} as dl, buyer_id, seller_id, logistics_id FROM orders WHERE status = ? AND ${r.deadlineCol} IS NOT NULL AND datetime(${r.deadlineCol}) > datetime('now') AND datetime(${r.deadlineCol}) <= datetime(?)`;
|
|
265
|
+
const rows = db.prepare(sql).all(r.status, new Date(targetMs).toISOString().replace('T', ' ').slice(0, 19));
|
|
266
|
+
for (const row of rows) {
|
|
267
|
+
const recipientId = r.recipientRole === 'buyer' ? row.buyer_id :
|
|
268
|
+
r.recipientRole === 'seller' ? row.seller_id :
|
|
269
|
+
r.recipientRole === 'logistics' ? row.logistics_id : null;
|
|
270
|
+
if (!recipientId)
|
|
271
|
+
continue;
|
|
272
|
+
// 幂等:该 (order, type) 是否已发过
|
|
273
|
+
const exists = db.prepare(`SELECT 1 FROM notifications WHERE order_id = ? AND type = ? LIMIT 1`).get(row.id, r.type);
|
|
274
|
+
if (exists)
|
|
275
|
+
continue;
|
|
276
|
+
const ctx = getOrderCtx(db, row.id);
|
|
277
|
+
if (!ctx)
|
|
278
|
+
continue;
|
|
279
|
+
createNotification(db, recipientId, row.id, r.type, r.title, r.body(ctx, r.hoursBefore));
|
|
280
|
+
details.push({ orderId: row.id, type: r.type });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return { sent: details.length, details };
|
|
284
|
+
}
|