reports-store.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335
  1. 'use strict';
  2. /**
  3. * 数据报表 · 本地数据湖
  4. * ----------------------------------------------------------------------------
  5. * 把从淘宝闪购商家后台取回的经营数据沉淀到本地 JSON 文件,支持:
  6. * · 多主体(每登录一个商家账号即一个主体 entity)
  7. * · 主体内分:全公司 / 连锁 / 单店 三种粒度的行
  8. * · 按「日期 × 门店」upsert 合并,可反复同步同一区间不产生重复
  9. *
  10. * 文件布局(项目根 data/reports/):
  11. * entities.json 主体注册表(含快照的连锁/门店结构)
  12. * <entityKey>/daily-YYYYMM.json 按月分片的数据行(行键 date|scope|shopId)
  13. * <entityKey>/daily.json 旧版单文件;读到的首次写入时自动迁移为分片并删除
  14. * <entityKey>/syncs.json 同步历史
  15. *
  16. * 按月分片的意义:同步/查询只触碰命中月份的分片,重写体积有上界;
  17. * 过期修剪等价于删除旧月份文件。
  18. */
  19. const fs = require('fs');
  20. const path = require('path');
  21. // 湖根目录:GENPIC_LAKE_DIR 环境变量可覆盖(smoke 测试隔离用,生产不受影响)
  22. const LAKE_DIR = process.env.GENPIC_LAKE_DIR
  23. ? path.resolve(process.env.GENPIC_LAKE_DIR)
  24. : path.join(__dirname, 'data', 'reports');
  25. // ---------- 通用 ----------
  26. function ensureDir(p) {
  27. fs.mkdirSync(p, { recursive: true });
  28. return p;
  29. }
  30. function readJsonSafe(file, fallback) {
  31. try {
  32. return JSON.parse(fs.readFileSync(file, 'utf8'));
  33. } catch {
  34. return fallback;
  35. }
  36. }
  37. function writeJsonAtomic(file, obj) {
  38. ensureDir(path.dirname(file));
  39. const temp = file + '.tmp';
  40. fs.writeFileSync(temp, JSON.stringify(obj), 'utf8');
  41. fs.renameSync(temp, file);
  42. }
  43. function sanitizeKey(v) {
  44. return String(v || '').replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 64) || 'default';
  45. }
  46. // ---------- 主体注册表 ----------
  47. function registryFile() {
  48. return path.join(LAKE_DIR, 'entities.json');
  49. }
  50. function loadRegistry() {
  51. return readJsonSafe(registryFile(), { entities: [] });
  52. }
  53. function saveRegistry(reg) {
  54. writeJsonAtomic(registryFile(), reg);
  55. }
  56. /**
  57. * 注册 / 更新一个主体(来自 shopTree 快照)。
  58. * 返回 { entity, created }。
  59. */
  60. function upsertEntity({ merchantId, merchantName, chains = [], shops = [] }) {
  61. const reg = loadRegistry();
  62. const key = sanitizeKey(merchantId);
  63. const now = new Date().toISOString();
  64. let entity = reg.entities.find((e) => e.key === key);
  65. let created = false;
  66. if (!entity) {
  67. created = true;
  68. entity = { key, firstSeen: now, lastSeen: now };
  69. reg.entities.push(entity);
  70. }
  71. entity.lastSeen = now;
  72. entity.merchantId = merchantId || null;
  73. entity.name = merchantName || entity.name || key;
  74. entity.chainCount = chains.length;
  75. entity.shopCount = shops.length;
  76. entity.chains = chains;
  77. entity.shops = shops;
  78. saveRegistry(reg);
  79. return { entity, created };
  80. }
  81. function listEntities() {
  82. const reg = loadRegistry();
  83. return reg.entities
  84. .slice()
  85. .sort((a, b) => (a.lastSeen < b.lastSeen ? 1 : -1));
  86. }
  87. // ---------- 主体结构:行反推树 ----------
  88. // 原则:数据中台/趋势图表/智能问数的门店结构以数据湖为准——湖里有什么店,结构就是什么。
  89. // 只读机(无商家客户端)拿不到 shopTree,注册表树为空,结构元数据只能来自数据行本身。
  90. /** 从行反推连锁/门店结构(纯函数)。company 行的 chainId=-1 不算连锁;无 ID 门店跳过。 */
  91. function treeFromRows(rows) {
  92. const chains = new Map();
  93. const shops = new Map();
  94. for (const r of rows || []) {
  95. const chainId = String(r.chainId || '');
  96. const chainName = String(r.chainName || '');
  97. if ((r.scope === 'chain' || r.scope === 'shop') && chainId && chainId !== '-1' && !chains.has(chainId)) {
  98. chains.set(chainId, { chainId, chainName });
  99. }
  100. if (r.scope === 'shop' && r.shopId && !shops.has(String(r.shopId))) {
  101. shops.set(String(r.shopId), { shopId: String(r.shopId), shopName: String(r.shopName || ''), chainId, chainName });
  102. }
  103. }
  104. return { chains: [...chains.values()], shops: [...shops.values()] };
  105. }
  106. /** 注册表树 ∪ 行反推树:注册表条目优先(保留 busyLevel 等客户端字段),行补空缺。 */
  107. function mergeTree(registry, derived) {
  108. const chains = new Map((registry.chains || []).map((c) => [String(c.chainId), c]));
  109. for (const c of derived.chains) if (!chains.has(c.chainId)) chains.set(c.chainId, c);
  110. const shops = new Map((registry.shops || []).map((s) => [String(s.shopId), s]));
  111. for (const s of derived.shops) if (!shops.has(s.shopId)) shops.set(s.shopId, s);
  112. return { chains: [...chains.values()], shops: [...shops.values()] };
  113. }
  114. /** listEntities + 行反推树合并:数据中台/趋势图表/智能问数等展示层用这份。 */
  115. function listEntitiesFull() {
  116. return listEntities().map((e) => {
  117. const merged = mergeTree(e, treeFromRows(loadRows(e.key)));
  118. return { ...e, chains: merged.chains, shops: merged.shops, chainCount: merged.chains.length, shopCount: merged.shops.length };
  119. });
  120. }
  121. function getEntity(key) {
  122. return loadRegistry().entities.find((e) => e.key === sanitizeKey(key)) || null;
  123. }
  124. // ---------- 数据行(按月分片) ----------
  125. const SHARD_RE = /^daily-(\d{6}|misc)\.json$/;
  126. function entityDir(entityKey) {
  127. return path.join(LAKE_DIR, sanitizeKey(entityKey));
  128. }
  129. function legacyFile(entityKey) {
  130. return path.join(entityDir(entityKey), 'daily.json');
  131. }
  132. function shardFile(entityKey, month) {
  133. return path.join(entityDir(entityKey), `daily-${month}.json`);
  134. }
  135. /** 行的月份桶:date 取 YYYYMMDD 前六位;异常格式进 misc 桶兜底 */
  136. function monthOf(row) {
  137. const m = /^(\d{6})/.exec(String(row.date || ''));
  138. return m ? m[1] : 'misc';
  139. }
  140. function sortRows(a, b) {
  141. return a.date === b.date
  142. ? String(a.shopId).localeCompare(String(b.shopId))
  143. : (a.date < b.date ? -1 : 1);
  144. }
  145. // 分片解析缓存:数据中台/明细/湖全景每个请求都会 loadRows,mtime+size 未变的
  146. // 文件直接复用上次解析结果,省掉重复 JSON.parse。写路径(upsert/save/delete)
  147. // 改动文件后 mtime 变化,缓存自然失效;缓存行对象为共享引用,调用方只读不改。
  148. const shardCache = new Map(); // 绝对路径 -> { mtimeMs, size, rows }
  149. function readShardRows(file, into) {
  150. let stat = null;
  151. try { stat = fs.statSync(file); } catch { shardCache.delete(file); return; }
  152. const hit = shardCache.get(file);
  153. if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) {
  154. for (const r of hit.rows) into.set(rowKey(r), r);
  155. return;
  156. }
  157. const data = readJsonSafe(file, null);
  158. if (data && Array.isArray(data.rows)) {
  159. for (const r of data.rows) if (r && r.date) into.set(rowKey(r), r);
  160. shardCache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, rows: data.rows });
  161. } else {
  162. shardCache.delete(file);
  163. }
  164. }
  165. /**
  166. * 全量视图:旧单文件 + 各月分片合并(分片优先于旧文件,覆盖迁移中断窗口)。
  167. * 对上层仍是「一个 rows 数组」,与分片前语义一致。
  168. */
  169. function loadRows(entityKey) {
  170. const byKey = new Map();
  171. readShardRows(legacyFile(entityKey), byKey);
  172. let shards = [];
  173. try {
  174. shards = fs.readdirSync(entityDir(entityKey)).filter((f) => SHARD_RE.test(f)).sort();
  175. } catch { /* 目录不存在 */ }
  176. for (const f of shards) readShardRows(path.join(entityDir(entityKey), f), byKey);
  177. const rows = [...byKey.values()];
  178. rows.sort(sortRows);
  179. return rows;
  180. }
  181. function writeShard(entityKey, month, rows) {
  182. writeJsonAtomic(shardFile(entityKey, month), {
  183. version: 2, updated: new Date().toISOString(), rows: rows.slice().sort(sortRows),
  184. });
  185. }
  186. function removeLegacyFile(entityKey) {
  187. try { fs.unlinkSync(legacyFile(entityKey)); } catch { /* 已不存在 */ }
  188. }
  189. /** 整体替换某主体全部行(语义与旧 saveRows 一致),会清掉不再存在的月份分片 */
  190. function saveRows(entityKey, rows) {
  191. const byMonth = new Map();
  192. for (const r of rows) {
  193. if (!r || !r.date) continue;
  194. const m = monthOf(r);
  195. if (!byMonth.has(m)) byMonth.set(m, []);
  196. byMonth.get(m).push(r);
  197. }
  198. const dir = entityDir(entityKey);
  199. try {
  200. for (const f of fs.readdirSync(dir)) {
  201. const m = SHARD_RE.exec(f);
  202. if (m && !byMonth.has(m[1])) fs.unlinkSync(path.join(dir, f));
  203. }
  204. } catch { /* 目录不存在则写时自建 */ }
  205. for (const [m, list] of byMonth) writeShard(entityKey, m, list);
  206. removeLegacyFile(entityKey);
  207. }
  208. function rowKey(row) {
  209. // scope 与 chainId 都必须参与键:全公司行、连锁行、区间汇总行的 shopId 都为空串,
  210. // 缺 chainId 时同日多个连锁/汇总行会互相覆盖(只留最后写入的一条)
  211. return `${row.date}|${row.scope || ''}|${row.chainId == null ? '' : String(row.chainId)}|${row.shopId == null ? '' : String(row.shopId)}`;
  212. }
  213. /** 合并写入:按 date|scope|chainId|shopId upsert,浅字段覆盖(raw 整体替换)。
  214. * 只重写被本次数据触及的月份分片;旧单文件在首次写入时整体迁移。 */
  215. function upsertRows(entityKey, incoming) {
  216. if (!Array.isArray(incoming) || !incoming.length) return { added: 0, updated: 0, total: 0 };
  217. const rows = loadRows(entityKey);
  218. const index = new Map(rows.map((r, i) => [rowKey(r), i]));
  219. let added = 0;
  220. let updated = 0;
  221. const touched = new Set();
  222. for (const next of incoming) {
  223. if (!next || !next.date) continue;
  224. touched.add(monthOf(next));
  225. const k = rowKey(next);
  226. const prev = index.has(k) ? rows[index.get(k)] : null;
  227. if (prev) {
  228. Object.assign(prev, next, { raw: next.raw || prev.raw || null });
  229. updated += 1;
  230. } else {
  231. rows.push(next);
  232. index.set(k, rows.length - 1);
  233. added += 1;
  234. }
  235. }
  236. const hasLegacy = fs.existsSync(legacyFile(entityKey));
  237. const byMonth = new Map();
  238. for (const r of rows) {
  239. const m = monthOf(r);
  240. // 未触及且无旧文件要迁移的月份保留原分片,不重写
  241. if (!hasLegacy && !touched.has(m)) continue;
  242. if (!byMonth.has(m)) byMonth.set(m, []);
  243. byMonth.get(m).push(r);
  244. }
  245. for (const [m, list] of byMonth) writeShard(entityKey, m, list);
  246. if (hasLegacy) removeLegacyFile(entityKey);
  247. return { added, updated, total: rows.length };
  248. }
  249. /** 行过滤:主体 + 区间 + 门店 + 粒度 */
  250. function queryRows(entityKey, { from, to, shopId, scope } = {}) {
  251. return loadRows(entityKey).filter((r) => {
  252. if (from && r.date < from) return false;
  253. if (to && r.date > to) return false;
  254. if (shopId && String(r.shopId) !== String(shopId)) return false;
  255. if (scope && r.scope !== scope) return false;
  256. return true;
  257. });
  258. }
  259. function datesInLake(entityKey) {
  260. const set = new Set();
  261. for (const r of loadRows(entityKey)) set.add(r.date);
  262. return [...set].sort();
  263. }
  264. function deleteEntityData(entityKey) {
  265. const dir = path.join(LAKE_DIR, sanitizeKey(entityKey));
  266. // 先摘掉该主体的分片缓存,避免重建同名主体时短暂引用已删除的旧行对象
  267. for (const f of [...shardCache.keys()]) {
  268. if (f.startsWith(dir + path.sep)) shardCache.delete(f);
  269. }
  270. fs.rmSync(dir, { recursive: true, force: true });
  271. }
  272. // ---------- 同步历史 ----------
  273. function syncsFile(entityKey) {
  274. return path.join(LAKE_DIR, sanitizeKey(entityKey), 'syncs.json');
  275. }
  276. function appendSyncLog(entityKey, entry) {
  277. const file = syncsFile(entityKey);
  278. const data = readJsonSafe(file, { runs: [] });
  279. data.runs.unshift(entry);
  280. if (data.runs.length > 50) data.runs.length = 50;
  281. writeJsonAtomic(file, data);
  282. }
  283. function loadSyncLogs(entityKey) {
  284. return readJsonSafe(syncsFile(entityKey), { runs: [] }).runs;
  285. }
  286. module.exports = {
  287. LAKE_DIR,
  288. loadRegistry, saveRegistry, upsertEntity, listEntities, listEntitiesFull, getEntity,
  289. loadRows, saveRows, upsertRows, queryRows, datesInLake, deleteEntityData, rowKey,
  290. appendSyncLog, loadSyncLogs,
  291. treeFromRows, mergeTree,
  292. sanitizeKey,
  293. };