trustbase-seller-backend 0.7.2 → 0.7.4

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.
@@ -0,0 +1,145 @@
1
+ "use strict";
2
+ /**
3
+ * chain-reconcile.js — 链上 slot 与本地 store_products 对账
4
+ *
5
+ * 原则:
6
+ * - 链上 ACTIVE、本地未标记:自动 recover 本地(仅补链上已有的状态)
7
+ * - 本地已标记、链上不存在:只告警,不自动删除/清除本地状态
8
+ * - 链上读失败:显式 degraded,不静默把本地缓存当真
9
+ */
10
+
11
+ const ACTIVE = "SLOT_STATUS_ACTIVE";
12
+
13
+ function ensureTables(db) {
14
+ db.exec(`
15
+ CREATE TABLE IF NOT EXISTS chain_reconcile_log (
16
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
17
+ sku_id TEXT NOT NULL DEFAULT '',
18
+ seller_address TEXT NOT NULL DEFAULT '',
19
+ action TEXT NOT NULL,
20
+ detail TEXT NOT NULL DEFAULT '',
21
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
22
+ );
23
+ CREATE TABLE IF NOT EXISTS chain_reconcile_state (
24
+ id INTEGER PRIMARY KEY CHECK (id = 1),
25
+ last_run_at TEXT NOT NULL DEFAULT (datetime('now')),
26
+ status TEXT NOT NULL,
27
+ detail TEXT NOT NULL DEFAULT ''
28
+ );
29
+ `);
30
+ }
31
+
32
+ function logItem(db, skuId, seller, action, detail) {
33
+ db.prepare("INSERT INTO chain_reconcile_log (sku_id, seller_address, action, detail) VALUES (?, ?, ?, ?)")
34
+ .run(skuId || "", seller || "", action, String(detail || "").slice(0, 800));
35
+ }
36
+
37
+ function saveState(db, status, detail) {
38
+ db.prepare(`INSERT OR REPLACE INTO chain_reconcile_state (id, last_run_at, status, detail)
39
+ VALUES (1, datetime('now'), ?, ?)`).run(status, String(detail || "").slice(0, 1000));
40
+ }
41
+
42
+ function getChainReconcileStatus(db) {
43
+ ensureTables(db);
44
+ return db.prepare("SELECT * FROM chain_reconcile_state WHERE id = 1").get() || null;
45
+ }
46
+
47
+ async function fetchSlots(lcdUrl, seller) {
48
+ const url = `${lcdUrl.replace(/\/$/, "")}/oxiaom/trustchain/marketplace/v1/sellers/${encodeURIComponent(seller)}/slots`;
49
+ const res = await fetch(url, { signal: AbortSignal.timeout(10000) });
50
+ if (!res.ok) throw new Error(`LCD ${res.status} ${url}`);
51
+ const body = await res.json();
52
+ const list = body && body.slots && Array.isArray(body.slots.slots) ? body.slots.slots : [];
53
+ return list;
54
+ }
55
+
56
+ function updateLocalFromSlot(db, product, slot) {
57
+ const createdMs = Number(slot.created_at) > 0 ? Number(slot.created_at) * 1000 : Date.now();
58
+ db.prepare(`UPDATE store_products SET
59
+ chain_listed = 1,
60
+ chain_sku_id = ?,
61
+ chain_slot_index = ?,
62
+ chain_stake_per_unit = ?,
63
+ chain_listed_at = ?
64
+ WHERE id = ?`)
65
+ .run(slot.sku_id, Number(slot.slot_index), String(slot.stake_per_unit || ""), createdMs, product.id);
66
+ }
67
+
68
+ /**
69
+ * 单次对账。返回 { status, recovered, warnings, errors, checked }。
70
+ */
71
+ async function runChainReconcile({ db, lcdUrl }) {
72
+ ensureTables(db);
73
+ const sellers = db.prepare("SELECT DISTINCT seller_address FROM store_products WHERE seller_address IS NOT NULL AND seller_address != ''").all()
74
+ .map((r) => r.seller_address);
75
+ const summary = { status: "success", recovered: 0, warnings: 0, errors: 0, checked: 0 };
76
+
77
+ for (const seller of sellers) {
78
+ let slots;
79
+ try {
80
+ slots = await fetchSlots(lcdUrl, seller);
81
+ } catch (e) {
82
+ summary.errors += 1;
83
+ summary.status = "degraded";
84
+ logItem(db, "", seller, "lcd_error", e.message);
85
+ continue; // 该卖家的本地数据一律不动
86
+ }
87
+
88
+ const localProducts = db.prepare("SELECT * FROM store_products WHERE seller_address = ?").all(seller);
89
+ const localBySku = new Map(localProducts.map((p) => [p.id, p]));
90
+ const activeBySku = new Map(slots.filter((s) => s.status === ACTIVE).map((s) => [s.sku_id, s]));
91
+
92
+ for (const p of localProducts) {
93
+ summary.checked += 1;
94
+ const slot = activeBySku.get(p.id);
95
+ if (slot) {
96
+ const needsRecover = !p.chain_listed || p.chain_sku_id !== slot.sku_id ||
97
+ Number(p.chain_slot_index) !== Number(slot.slot_index) ||
98
+ String(p.chain_stake_per_unit || "") !== String(slot.stake_per_unit || "");
99
+ if (needsRecover) {
100
+ updateLocalFromSlot(db, p, slot);
101
+ summary.recovered += 1;
102
+ logItem(db, p.id, seller, "recovered", `slot_index=${slot.slot_index}`);
103
+ }
104
+ } else if (p.chain_listed) {
105
+ summary.warnings += 1;
106
+ logItem(db, p.id, seller, "chain_missing", "本地 chain_listed=1 但链上无 ACTIVE slot;不自动清理");
107
+ }
108
+ }
109
+
110
+ // 链上 ACTIVE 但本地完全没有该商品:只记孤儿,不自动创建商品。
111
+ for (const slot of activeBySku.values()) {
112
+ if (!localBySku.has(slot.sku_id)) {
113
+ summary.warnings += 1;
114
+ logItem(db, slot.sku_id, seller, "orphan_chain_slot", `slot_index=${slot.slot_index}`);
115
+ }
116
+ }
117
+ }
118
+
119
+ saveState(db, summary.status, JSON.stringify(summary));
120
+ return summary;
121
+ }
122
+
123
+ function startChainReconcile({ db, lcdUrl }) {
124
+ if (process.env.CHAIN_RECONCILE_ENABLED === "false") return null;
125
+ const intervalMs = Number(process.env.CHAIN_RECONCILE_INTERVAL_MS || 3600000);
126
+ let running = false;
127
+ const tick = async () => {
128
+ if (running) return;
129
+ running = true;
130
+ try {
131
+ const r = await runChainReconcile({ db, lcdUrl });
132
+ console.log(`[ChainReconcile] ${r.status}: recovered=${r.recovered} warnings=${r.warnings} errors=${r.errors} checked=${r.checked}`);
133
+ } catch (e) {
134
+ console.error("[ChainReconcile] failed:", e.message);
135
+ } finally {
136
+ running = false;
137
+ }
138
+ };
139
+ setImmediate(tick);
140
+ const timer = setInterval(tick, intervalMs);
141
+ if (timer.unref) timer.unref();
142
+ return timer;
143
+ }
144
+
145
+ module.exports = { runChainReconcile, startChainReconcile, getChainReconcileStatus, ensureTables };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trustbase-seller-backend",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "TrustBase Seller Backend (merchant edition) — 商品上链/订单/支付/质押 write API for TrustBase 保障链生态. 单卖家自托管形态: 签名 key 即卖家身份, 私钥不出门.",
5
5
  "main": "run-unified.js",
6
6
  "bin": {
package/run-unified.js CHANGED
@@ -53,6 +53,7 @@ exports.startUnifiedServer = startUnifiedServer;
53
53
  require('./core/boot-env').loadBootEnv();
54
54
  const base_paths_1 = require("./core/paths");
55
55
  const chain_write_log_1 = require("./core/chain-write-log");
56
+ const chain_reconcile_1 = require("./core/chain-reconcile");
56
57
  const env_1 = require("./utils/env");
57
58
  const express_1 = __importDefault(require("express"));
58
59
  const cors_1 = __importDefault(require("cors"));
@@ -295,6 +296,11 @@ async function startUnifiedServer() {
295
296
  await indexer.start();
296
297
  // 2026-10-17 kimi: 初始化上链尝试审计(append-only,独立于幂等表 chain_write_log)。
297
298
  chain_write_log_1.initChainWriteLogger(sqliteIndexer.getRawDb());
299
+ // 2026-10-17 kimi: 启动时 + 定时对账;链上读失败显式 degraded,不静默返回缓存。
300
+ chain_reconcile_1.startChainReconcile({
301
+ db: sqliteIndexer.getRawDb(),
302
+ lcdUrl: process.env.CHAIN_LCD_URL || process.env.LCD_URL || 'http://localhost:1317',
303
+ });
298
304
  // ── F-01 fix: ChainListener 接回 run-unified ──
299
305
  // SQLitePlugin.start() 本身不 wire ChainListener (只有 TrustCoinIndexer.start() wire).
300
306
  // 原来 run.ts 走 TrustCoinIndexer 所以 listener 自动起来, run-unified 直用
@@ -917,7 +923,13 @@ async function startUnifiedServer() {
917
923
  }));
918
924
  console.log(` ✅ Chain RPC/LCD proxy mounted: /rpc -> ${CHAIN_RPC_PROXY_TARGET}, /lcd -> ${CHAIN_LCD_PROXY_TARGET}`);
919
925
  // 7.5 静态文件托管(PRD-E §5.1)
920
- const webDist = node_path_1.default.resolve(__dirname, '../../web/dist');
926
+ // 2026-10-17 kimi: 买家 PWA 已内嵌到包内 web/dist;优先包内路径,
927
+ // 包外 ../../web/dist 仅作老部署兼容,避免“文件装上了却打不开页面”。
928
+ const webDistCandidates = [
929
+ node_path_1.default.resolve(__dirname, 'web/dist'),
930
+ node_path_1.default.resolve(__dirname, '../../web/dist'),
931
+ ];
932
+ const webDist = webDistCandidates.find((p) => node_fs_1.default.existsSync(p)) || webDistCandidates[0];
921
933
  if (node_fs_1.default.existsSync(webDist)) {
922
934
  // index.html 不缓存 (baobei 15442): index.html 引用带 hash 的 assets, 缓存旧版会引旧 hash → 404/旧内容.
923
935
  // assets 本身 (带 content hash) 走 maxAge 长缓存.
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+ /**
3
+ * test-chain-reconcile.js — 链上/本地对账离线单测
4
+ * node --test test-chain-reconcile.js
5
+ */
6
+ const test = require("node:test");
7
+ const assert = require("node:assert");
8
+ const http = require("http");
9
+ const Database = require("better-sqlite3");
10
+ const { runChainReconcile, getChainReconcileStatus } = require("./core/chain-reconcile");
11
+
12
+ const SELLER = "tct1seller";
13
+ function makeDb() {
14
+ const db = new Database(":memory:");
15
+ db.exec(`CREATE TABLE store_products (
16
+ id TEXT PRIMARY KEY,
17
+ seller_address TEXT NOT NULL,
18
+ title TEXT NOT NULL DEFAULT '',
19
+ chain_sku_id TEXT,
20
+ chain_slot_index INTEGER,
21
+ chain_listed INTEGER NOT NULL DEFAULT 0,
22
+ chain_listed_at INTEGER,
23
+ chain_tx_hash TEXT,
24
+ chain_stake_per_unit TEXT
25
+ )`);
26
+ return db;
27
+ }
28
+ function insertProduct(db, id, listed = 0) {
29
+ db.prepare("INSERT INTO store_products (id, seller_address, title, chain_listed, chain_sku_id, chain_slot_index, chain_stake_per_unit) VALUES (?, ?, ?, ?, ?, ?, ?)")
30
+ .run(id, SELLER, id, listed, listed ? id : null, listed ? 0 : null, listed ? "3000000" : null);
31
+ }
32
+ function activeSlot(skuId) {
33
+ return { seller: SELLER, slot_index: "0", sku_id: skuId, status: "SLOT_STATUS_ACTIVE", stake_per_unit: "3000000", created_at: "1790170236" };
34
+ }
35
+ async function startLcd(handler) {
36
+ const srv = http.createServer((req, res) => {
37
+ if (req.url.includes("/slots")) {
38
+ const out = handler(req.url);
39
+ if (out.error) { res.writeHead(500); return res.end(out.error); }
40
+ res.writeHead(200, { "Content-Type": "application/json" });
41
+ return res.end(JSON.stringify({ slots: { slots: out.slots || [] } }));
42
+ }
43
+ res.writeHead(404); res.end();
44
+ });
45
+ srv.listen(0);
46
+ await new Promise((r) => srv.once("listening", r));
47
+ return { srv, url: `http://127.0.0.1:${srv.address().port}` };
48
+ }
49
+
50
+ test("链上 ACTIVE、本地未标记 → 自动 recover,不补造 txhash", async () => {
51
+ const db = makeDb(); insertProduct(db, "sku-1", 0);
52
+ const { srv, url } = await startLcd(() => ({ slots: [activeSlot("sku-1")] }));
53
+ try {
54
+ const r = await runChainReconcile({ db, lcdUrl: url });
55
+ assert.strictEqual(r.status, "success");
56
+ assert.strictEqual(r.recovered, 1);
57
+ const p = db.prepare("SELECT * FROM store_products WHERE id = 'sku-1'").get();
58
+ assert.strictEqual(p.chain_listed, 1);
59
+ assert.strictEqual(p.chain_slot_index, 0);
60
+ assert.strictEqual(p.chain_stake_per_unit, "3000000");
61
+ assert.strictEqual(p.chain_tx_hash, null, "不编造 txhash");
62
+ assert.strictEqual(db.prepare("SELECT action FROM chain_reconcile_log").get().action, "recovered");
63
+ } finally { srv.close(); db.close(); }
64
+ });
65
+
66
+ test("本地已上架、链上无 ACTIVE → 只告警,不自动清理", async () => {
67
+ const db = makeDb(); insertProduct(db, "sku-1", 1);
68
+ const { srv, url } = await startLcd(() => ({ slots: [] }));
69
+ try {
70
+ const r = await runChainReconcile({ db, lcdUrl: url });
71
+ assert.strictEqual(r.warnings, 1);
72
+ assert.strictEqual(db.prepare("SELECT chain_listed FROM store_products WHERE id = 'sku-1'").get().chain_listed, 1);
73
+ assert.strictEqual(db.prepare("SELECT action FROM chain_reconcile_log").get().action, "chain_missing");
74
+ } finally { srv.close(); db.close(); }
75
+ });
76
+
77
+ test("链上读失败 → degraded,本地不被改动", async () => {
78
+ const db = makeDb(); insertProduct(db, "sku-1", 1);
79
+ const before = db.prepare("SELECT * FROM store_products WHERE id = 'sku-1'").get();
80
+ const { srv, url } = await startLcd(() => ({ error: "boom" }));
81
+ try {
82
+ const r = await runChainReconcile({ db, lcdUrl: url });
83
+ assert.strictEqual(r.status, "degraded");
84
+ const after = db.prepare("SELECT * FROM store_products WHERE id = 'sku-1'").get();
85
+ assert.deepStrictEqual(after, before);
86
+ assert.strictEqual(getChainReconcileStatus(db).status, "degraded");
87
+ } finally { srv.close(); db.close(); }
88
+ });
89
+
90
+ test("链上孤儿 slot → 只记录,不自动创建商品", async () => {
91
+ const db = makeDb();
92
+ insertProduct(db, "other", 0); // 有卖家可查,但没有与 orphan-1 对应的本地商品
93
+ const { srv, url } = await startLcd(() => ({ slots: [activeSlot("orphan-1")] }));
94
+ try {
95
+ const r = await runChainReconcile({ db, lcdUrl: url });
96
+ assert.strictEqual(r.warnings, 1);
97
+ assert.strictEqual(db.prepare("SELECT count(*) c FROM store_products WHERE id = 'orphan-1'").get().c, 0);
98
+ assert.strictEqual(db.prepare("SELECT action FROM chain_reconcile_log").get().action, "orphan_chain_slot");
99
+ } finally { srv.close(); db.close(); }
100
+ });
101
+
102
+ test("已经一致 → 不重复 recover", async () => {
103
+ const db = makeDb(); insertProduct(db, "sku-1", 1);
104
+ const { srv, url } = await startLcd(() => ({ slots: [activeSlot("sku-1")] }));
105
+ try {
106
+ const r = await runChainReconcile({ db, lcdUrl: url });
107
+ assert.strictEqual(r.recovered, 0);
108
+ } finally { srv.close(); db.close(); }
109
+ });