reports-store.js 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  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. function getEntity(key) {
  88. return loadRegistry().entities.find((e) => e.key === sanitizeKey(key)) || null;
  89. }
  90. // ---------- 数据行(按月分片) ----------
  91. const SHARD_RE = /^daily-(\d{6}|misc)\.json$/;
  92. function entityDir(entityKey) {
  93. return path.join(LAKE_DIR, sanitizeKey(entityKey));
  94. }
  95. function legacyFile(entityKey) {
  96. return path.join(entityDir(entityKey), 'daily.json');
  97. }
  98. function shardFile(entityKey, month) {
  99. return path.join(entityDir(entityKey), `daily-${month}.json`);
  100. }
  101. /** 行的月份桶:date 取 YYYYMMDD 前六位;异常格式进 misc 桶兜底 */
  102. function monthOf(row) {
  103. const m = /^(\d{6})/.exec(String(row.date || ''));
  104. return m ? m[1] : 'misc';
  105. }
  106. function sortRows(a, b) {
  107. return a.date === b.date
  108. ? String(a.shopId).localeCompare(String(b.shopId))
  109. : (a.date < b.date ? -1 : 1);
  110. }
  111. // 分片解析缓存:数据中台/明细/湖全景每个请求都会 loadRows,mtime+size 未变的
  112. // 文件直接复用上次解析结果,省掉重复 JSON.parse。写路径(upsert/save/delete)
  113. // 改动文件后 mtime 变化,缓存自然失效;缓存行对象为共享引用,调用方只读不改。
  114. const shardCache = new Map(); // 绝对路径 -> { mtimeMs, size, rows }
  115. function readShardRows(file, into) {
  116. let stat = null;
  117. try { stat = fs.statSync(file); } catch { shardCache.delete(file); return; }
  118. const hit = shardCache.get(file);
  119. if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size) {
  120. for (const r of hit.rows) into.set(rowKey(r), r);
  121. return;
  122. }
  123. const data = readJsonSafe(file, null);
  124. if (data && Array.isArray(data.rows)) {
  125. for (const r of data.rows) if (r && r.date) into.set(rowKey(r), r);
  126. shardCache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, rows: data.rows });
  127. } else {
  128. shardCache.delete(file);
  129. }
  130. }
  131. /**
  132. * 全量视图:旧单文件 + 各月分片合并(分片优先于旧文件,覆盖迁移中断窗口)。
  133. * 对上层仍是「一个 rows 数组」,与分片前语义一致。
  134. */
  135. function loadRows(entityKey) {
  136. const byKey = new Map();
  137. readShardRows(legacyFile(entityKey), byKey);
  138. let shards = [];
  139. try {
  140. shards = fs.readdirSync(entityDir(entityKey)).filter((f) => SHARD_RE.test(f)).sort();
  141. } catch { /* 目录不存在 */ }
  142. for (const f of shards) readShardRows(path.join(entityDir(entityKey), f), byKey);
  143. const rows = [...byKey.values()];
  144. rows.sort(sortRows);
  145. return rows;
  146. }
  147. function writeShard(entityKey, month, rows) {
  148. writeJsonAtomic(shardFile(entityKey, month), {
  149. version: 2, updated: new Date().toISOString(), rows: rows.slice().sort(sortRows),
  150. });
  151. }
  152. function removeLegacyFile(entityKey) {
  153. try { fs.unlinkSync(legacyFile(entityKey)); } catch { /* 已不存在 */ }
  154. }
  155. /** 整体替换某主体全部行(语义与旧 saveRows 一致),会清掉不再存在的月份分片 */
  156. function saveRows(entityKey, rows) {
  157. const byMonth = new Map();
  158. for (const r of rows) {
  159. if (!r || !r.date) continue;
  160. const m = monthOf(r);
  161. if (!byMonth.has(m)) byMonth.set(m, []);
  162. byMonth.get(m).push(r);
  163. }
  164. const dir = entityDir(entityKey);
  165. try {
  166. for (const f of fs.readdirSync(dir)) {
  167. const m = SHARD_RE.exec(f);
  168. if (m && !byMonth.has(m[1])) fs.unlinkSync(path.join(dir, f));
  169. }
  170. } catch { /* 目录不存在则写时自建 */ }
  171. for (const [m, list] of byMonth) writeShard(entityKey, m, list);
  172. removeLegacyFile(entityKey);
  173. }
  174. function rowKey(row) {
  175. // scope 与 chainId 都必须参与键:全公司行、连锁行、区间汇总行的 shopId 都为空串,
  176. // 缺 chainId 时同日多个连锁/汇总行会互相覆盖(只留最后写入的一条)
  177. return `${row.date}|${row.scope || ''}|${row.chainId == null ? '' : String(row.chainId)}|${row.shopId == null ? '' : String(row.shopId)}`;
  178. }
  179. /** 合并写入:按 date|scope|chainId|shopId upsert,浅字段覆盖(raw 整体替换)。
  180. * 只重写被本次数据触及的月份分片;旧单文件在首次写入时整体迁移。 */
  181. function upsertRows(entityKey, incoming) {
  182. if (!Array.isArray(incoming) || !incoming.length) return { added: 0, updated: 0, total: 0 };
  183. const rows = loadRows(entityKey);
  184. const index = new Map(rows.map((r, i) => [rowKey(r), i]));
  185. let added = 0;
  186. let updated = 0;
  187. const touched = new Set();
  188. for (const next of incoming) {
  189. if (!next || !next.date) continue;
  190. touched.add(monthOf(next));
  191. const k = rowKey(next);
  192. const prev = index.has(k) ? rows[index.get(k)] : null;
  193. if (prev) {
  194. Object.assign(prev, next, { raw: next.raw || prev.raw || null });
  195. updated += 1;
  196. } else {
  197. rows.push(next);
  198. index.set(k, rows.length - 1);
  199. added += 1;
  200. }
  201. }
  202. const hasLegacy = fs.existsSync(legacyFile(entityKey));
  203. const byMonth = new Map();
  204. for (const r of rows) {
  205. const m = monthOf(r);
  206. // 未触及且无旧文件要迁移的月份保留原分片,不重写
  207. if (!hasLegacy && !touched.has(m)) continue;
  208. if (!byMonth.has(m)) byMonth.set(m, []);
  209. byMonth.get(m).push(r);
  210. }
  211. for (const [m, list] of byMonth) writeShard(entityKey, m, list);
  212. if (hasLegacy) removeLegacyFile(entityKey);
  213. return { added, updated, total: rows.length };
  214. }
  215. /** 行过滤:主体 + 区间 + 门店 + 粒度 */
  216. function queryRows(entityKey, { from, to, shopId, scope } = {}) {
  217. return loadRows(entityKey).filter((r) => {
  218. if (from && r.date < from) return false;
  219. if (to && r.date > to) return false;
  220. if (shopId && String(r.shopId) !== String(shopId)) return false;
  221. if (scope && r.scope !== scope) return false;
  222. return true;
  223. });
  224. }
  225. function datesInLake(entityKey) {
  226. const set = new Set();
  227. for (const r of loadRows(entityKey)) set.add(r.date);
  228. return [...set].sort();
  229. }
  230. function deleteEntityData(entityKey) {
  231. const dir = path.join(LAKE_DIR, sanitizeKey(entityKey));
  232. // 先摘掉该主体的分片缓存,避免重建同名主体时短暂引用已删除的旧行对象
  233. for (const f of [...shardCache.keys()]) {
  234. if (f.startsWith(dir + path.sep)) shardCache.delete(f);
  235. }
  236. fs.rmSync(dir, { recursive: true, force: true });
  237. }
  238. // ---------- 同步历史 ----------
  239. function syncsFile(entityKey) {
  240. return path.join(LAKE_DIR, sanitizeKey(entityKey), 'syncs.json');
  241. }
  242. function appendSyncLog(entityKey, entry) {
  243. const file = syncsFile(entityKey);
  244. const data = readJsonSafe(file, { runs: [] });
  245. data.runs.unshift(entry);
  246. if (data.runs.length > 50) data.runs.length = 50;
  247. writeJsonAtomic(file, data);
  248. }
  249. function loadSyncLogs(entityKey) {
  250. return readJsonSafe(syncsFile(entityKey), { runs: [] }).runs;
  251. }
  252. module.exports = {
  253. LAKE_DIR,
  254. loadRegistry, saveRegistry, upsertEntity, listEntities, getEntity,
  255. loadRows, saveRows, upsertRows, queryRows, datesInLake, deleteEntityData, rowKey,
  256. appendSyncLog, loadSyncLogs,
  257. sanitizeKey,
  258. };