2026-10-20-manual-ops-import-design.md 25 KB

店铺运营数据上传 · 设计文档

  • 日期:2026-10-20(SQLite 先行 / PG 可切换版)
  • 分支:dev
  • 状态:待评审

1. 背景

现有经营数据只有一个入口:从本机淘宝闪购商家客户端按天采集(napos-service.js),写入本地数据湖 data/reports/。

实际使用中出现两类「采集之外」的数据:

文件 形态 用途
浙江工商桐庐校区20261018至20261018_全部门店.xlsx 平台导出,124 列 × 10 店 × 1 天 与采集同源,可补齐缺失日期
浙江工商桐庐校区20261019至20261019_全部门店.xlsx 同上 同上
店铺运营数据.xlsx 人工统计,6 基础列 + 29 天 × 4 指标,一行一店 跨校区运营看板

本文只覆盖第三类:人工统计表。前两类(平台导出)作为后续解析器接入同一套框架,不在本次范围。

为什么不能直接并进数据湖

data/reports/ 的组织维度是「主体 = 商家账号(merchantId)」,行键 date|scope|chainId|shopId,全公司/连锁行由采集时单独取回。人工统计表有三个结构性冲突:

  1. 跨主体:一份文件含 3 个学校(浙江科技学院 17 店、杭州师范大学下沙校区 15 店、浙江工商大学桐庐校区 9 店,共 36 家店,其中 34 家有 ID),不属于任何单一 merchantId
  2. 指标少:只有订单量、营收、曝光量、进店转化率 4 项,另 100+ 列在采集中存在而此处缺失
  3. 口径人工:数值由人从后台抄录,存在格式错误,可信度低于采集值

因此人工统计表独立存储、独立展示,不与采集数据合并。两者通过 shopId + date 可关联,为将来的「采集 vs 人工」差异视图留路。

存储位置

数据进关系库,但驱动可切换:

  • 本期(测试阶段):本地 SQLite(data/ops.db),用 Node 22 内置 node:sqlite,不引入依赖,整个流程离线可跑通
  • 上线阶段:云端 PostgreSQL,同一套表和接口,只换驱动和连接串

存储层抽象成统一接口(见 §5),两个实现互换,解析器与界面完全不知道下面是哪种库。

2. 目标与非目标

目标

  • 上传 店铺运营数据.xlsx,原件永久保留(字节级不变,可下载回)
  • 解析为规范行,写入关系库,可按店铺 / 学校 / 日期 / 指标查询
  • 界面以「报表矩阵」或「卡片」展示,指标由用户勾选,默认展示人工表的 4 个字段
  • 解析问题不丢数据:失败的单元格保留原文并标记,可回看
  • 支持对原件重新解析(解析规则迭代后不需要重新上传)
  • 本地 SQLite 跑通完整流程;切换云端 PG 只改配置,不改业务代码
  • 数据库不可用时服务照常启动,仅「店铺运营」功能不可用

非目标

  • 不做平台导出(124 列)的导入 —— 后续在同一框架上加解析器
  • 不做采集数据与人工数据的合并报表或差异对照
  • 不接入智能问数、趋势图表、钉钉 AI 表格推送
  • 不支持 .xls、.csv、在线编辑单元格、多文件一次上传
  • 不做多租户隔离(见 §13),本期单租户

3. 数据形态(实测)

店铺运营数据.xlsx:

  • Sheet1 尺寸 A1:DR43(122 列 × 43 行),Sheet2 为空
  • 表头一行:序号 | 序号 | 学校名称 | 店铺名称 | ID | 上线状态 | 9.9订单量 | 9.9营收 | 9.9曝光量 | 9.9进店转换率 | …,前两列都叫「序号」,第 1 列实际为空
  • 42 个数据行:36 行有店铺名,其中 34 行有店铺 ID、2 行无 ID(未上线);另 6 行是占位行(有学校名、无店铺名)
  • 日期列 29 组:9.9 ~ 10.7,每组 4 列(订单量 / 营收 / 曝光量 / 进店转换率),共 116 列
  • 上线状态分布(36 家):已上线 28、暂停营业 6、未上线 2
  • 解析后应有 36 × 29 = 1044 行

脏数据(真实存在,必须处理):

类型 实例 数量
转化率写成百分数字符串 10..6% 1
转化率写成百分数数值 12.3、4.9(应为 0.123 / 0.049) 2
非数值文本 未开放 1
转化率空白 None 179

4. 架构

浏览器
  │  POST /api/ops/upload  { fileName, contentBase64, year }
  ▼
ops-import.js        格式识别 → 解析器 → 行级清洗 → 警告收集
  │
  ▼
ops-store.js         统一存储接口(事务写入 / 查询 / 原件读取)
  │
  ├── stores/ops-sqlite.js   node:sqlite → data/ops.db        ← 本期默认
  └── stores/ops-pg.js       pg → 云端 PostgreSQL             ← 上线切换
  ▼
GET /api/ops/rows    筛选/矩阵查询
  ▼
public/ops.js        报表矩阵 / 卡片视图

模块划分(每个模块单一职责,可独立测试):

文件 职责 依赖
server/services/xlsx.js 读 xlsx:Buffer → { sheets: [{ name, rows: Cell[][] }] } Node 内置 zlib
ops-import.js 解析器注册表 + 人工统计表解析器(识别、宽表转长表、清洗) xlsx.js
ops-store.js 驱动选择、统一接口、迁移、脱敏日志 两个 driver
server/stores/ops-sqlite.js SQLite 实现(node:sqlite) 内置模块
server/stores/ops-pg.js PostgreSQL 实现(pg,本期不装) pg
server/routes/ops.js HTTP 端点 + 数据库不可用时的降级响应 上面几个 + util.js
public/ops.js 页面交互与渲染 无(原生 JS)

解析器以注册表组织([{ kind, detect(header), parse(cells, opts) }]),后续加平台导出格式时只追加一项,不动存储与界面。

5. 存储层

5.1 配置

config.json(模板已加好):

"storage": {
  "driver": "sqlite",
  "sqlite": { "file": "data/ops.db" },
  "postgres": {
    "connectionString": "",
    "ssl": true,
    "max": 5,
    "schema": "public",
    "connectTimeoutMs": 10000
  }
}
  • driver:sqlite(默认)| postgres
  • driver=sqlite:文件不存在时自动创建;data/ 已 gitignore
  • driver=postgres 且 connectionString 为空 → ops 功能禁用(服务不受影响)
  • 环境变量 OPS_DATABASE_URL 可覆盖 PG 连接串(部署与测试用)
  • node:sqlite 需要 Node ≥ 22.5,缺失时 ops 禁用并返回明确原因(其它功能不受影响)

5.2 统一接口

// ops-store.js 对外暴露
createStore(config) → {
  driver,                     // 'sqlite' | 'postgres'
  init(),                     // 建表 + schema_version(幂等,进程内只跑一次)
  health(),                   // { ok, driver, detail }
  withTransaction(fn),        // 事务包装,失败自动回滚
  insertUpload(tx, upload),   // 含 content BLOB/bytea
  upsertRows(tx, rows),       // ON CONFLICT (date, shop_key) DO UPDATE
  upsertShops(tx, shops),
  findUploadBySha(sha256),
  listUploads(),
  getUpload(id),              // 不含 content
  getContent(id),             // 只取原件字节
  updateReport(id, report),
  queryRows({ from, to, schools, shops }) → rows,
  summary(),                  // 学校/店铺/覆盖区间/计数
}

SQL 只在两个 driver 内部出现;ops-import.js 与路由层不感知方言。

5.3 表结构

SQLite 版(主):

CREATE TABLE IF NOT EXISTS ops_meta (
  key TEXT PRIMARY KEY, value TEXT NOT NULL
);  -- schema_version = '1'

CREATE TABLE IF NOT EXISTS ops_upload (
  id             TEXT PRIMARY KEY,        -- YYYYMMDD-HHmmss-xxxxxx(服务端生成)
  original_name  TEXT NOT NULL,
  sha256         TEXT NOT NULL,
  size_bytes     INTEGER NOT NULL,
  uploaded_at    TEXT NOT NULL,           -- ISO 8601
  year           INTEGER NOT NULL,
  parser_kind    TEXT NOT NULL,
  parser_version INTEGER NOT NULL,
  report         TEXT NOT NULL DEFAULT '{}',   -- JSON 文本
  content        BLOB NOT NULL                 -- 原件
);
CREATE INDEX IF NOT EXISTS ops_upload_sha256_idx ON ops_upload (sha256, uploaded_at DESC);

CREATE TABLE IF NOT EXISTS ops_shop (
  key            TEXT PRIMARY KEY,        -- 有 ID 用 ID,否则 school|shopName
  shop_id        TEXT,
  shop_name      TEXT NOT NULL,
  school         TEXT NOT NULL,
  status         TEXT,
  last_upload_id TEXT REFERENCES ops_upload(id) ON DELETE SET NULL,
  first_seen     TEXT NOT NULL,
  last_seen      TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS ops_row (
  date       TEXT NOT NULL,               -- YYYYMMDD
  shop_key   TEXT NOT NULL,
  upload_id  TEXT NOT NULL REFERENCES ops_upload(id) ON DELETE CASCADE,
  shop_id    TEXT,
  shop_name  TEXT NOT NULL,
  school     TEXT NOT NULL,
  status     TEXT,
  order_cnt  INTEGER,
  revenue    REAL,
  exposure   INTEGER,
  click_rate REAL,
  raw        TEXT NOT NULL DEFAULT '{}',  -- JSON 文本
  quality    TEXT NOT NULL DEFAULT '[]',  -- JSON 数组
  updated_at TEXT NOT NULL,
  PRIMARY KEY (date, shop_key)
);
CREATE INDEX IF NOT EXISTS ops_row_school_date_idx ON ops_row (school, date);

PG 版差异(同一接口,方言差异只在这几处):

SQLite PostgreSQL
BLOB bytea
TEXT 存时间 timestamptz
REAL numeric(14,2) / numeric(8,6)
JSON 文本 jsonb
quality JSON 文本 text[]

SQLite 连接参数:journal_mode=WAL、busy_timeout=5000、foreign_keys=ON。

5.4 写入流程(单事务)

  1. BEGIN
  2. INSERT INTO ops_upload (...)
  3. 批量 INSERT INTO ops_row ... ON CONFLICT (date, shop_key) DO UPDATE SET ...(每批 200 行)
  4. INSERT INTO ops_shop ... ON CONFLICT (key) DO UPDATE SET ...
  5. COMMIT(失败则 ROLLBACK,本次上传不留残留)

5.5 覆盖与版本

  • 同日同店再次导入 → ON CONFLICT DO UPDATE 覆盖 4 个指标、raw、upload_id,返回 overwritten 计数
  • 每次上传的 ops_upload 行都保留(含原件),历史可追溯
  • 重新解析:读 content → 重新生成行 → 同一事务内 upsert → 更新 report;id 与 content 不变

5.6 从 SQLite 切到 PG

  1. config.json 改 driver: "postgres" 并填 connectionString
  2. npm run db:init 建表
  3. 需要历史数据时,用 npm run db:export(SQLite → JSON)与 db:import(JSON → PG)搬一次;不需要则直接空库开始

两个脚本在实施阶段一并提供(小工具,复用现有接口)。

6. 解析规则

6.1 格式识别

表头同时出现下列两类以上即判定为人工统计表:

  • 含「订单量」「营收」「曝光量」字样
  • 含「转换率」(人工表用「转换」,平台用「转化」,两者都接受)

识别失败 → 400,返回支持的格式说明,不落库。

6.2 日期

  • 列名 9.9订单量 → 月 9、日 9
  • 年份取上传时传入的 year(弹窗默认当前年),落库为 YYYYMMDD
  • 跨年判断:按列出现顺序,出现「月份比前一列小 6 以上」时年份 +1(如 12.31 之后出现 1.1)
  • 月或日超出 1–12 / 1–31 → 跳过该列并记 warning

6.3 列定位

按表头名称定位,不依赖列序号:

  • 学校名称、店铺名称、ID、上线状态 按精确名匹配,容忍前后空格
  • 同日 4 列按「日期 + 指标名」组合定位,顺序无关
  • 缺失必需列(店铺名称)→ 识别失败
  • 重复「序号」列、首列空白不影响

6.4 值清洗

指标内部键与采集口径对齐,便于将来对照:

人工表列 内部键 采集对应字段 类型
订单量 order_cnt validOrderCnt 整数
营收 revenue grossAmt 数值(元)
曝光量 exposure expUv 整数
进店转换率 click_rate clkExpRate 比率 0–1

清洗顺序(对每个单元格):

  1. null / 空串 → NULL + missing
  2. 字符串去空白;含 % 或 % → 去掉符号,标记 pct-symbol
  3. 折叠重复小数点(10..6 → 10.6)→ 标记 malformed-percent
  4. Number() 解析;失败且非空(未开放 等)→ NULL + non-numeric,原文进 raw
  5. 比率指标且数值 > 1 → 除以 100,标记 percent-scaled
  6. 比率结果 > 1 或 < 0 → 保留并标记 out-of-range
  7. 数量类指标为负 → 置 0 并标记 negative

6.5 行过滤

跳过(不产生 warning,属正常结构):

  • 店铺名称为空(含 6 个占位行)
  • 该店在所有日期上都没有任何一个数值(整行空店)

有数据的店保留全部日期,当天四项皆空也留一行(值全为 NULL),矩阵显示「–」, 保证「店 × 天」结构完整(36 × 29 = 1044 行)。

保留但标记:

  • 店铺 ID 为空但店铺名存在 → 用 school|shopName 作 shop_key,quality 加 no-shop-id
  • 状态为空 → 保留 NULL

6.6 解析器实现(零依赖)

server/services/xlsx.js 只做一件事:把 xlsx 解码成二维单元格数组。

xlsx 是 ZIP 容器,实现路径:

  1. 解析 ZIP 中央目录,取 xl/workbook.xml、xl/worksheets/sheet1.xml、xl/sharedStrings.xml
  2. zlib.inflateRawSync 解压(store 方式直接切片)
  3. 正则提取 <row> / <c> / <v> / <is><t>;类型 s 查共享字符串表,无 <v> 视为空
  4. 合并单元格按左上角取值,其余为空(本格式用不到合并)
  5. 日期单元格若为 Excel 序列号且列名疑似日期,按 1899-12-30 基准换算(本格式日期是字符串,不触发)

不做:公式计算(读缓存值)、样式、多 sheet 遍历(只读第一个非空 sheet)、.xls 二进制。

解析异常 → 400「文件可能不是标准 xlsx」,不入库。

7. 接口

所有端点挂在 server/routes/ops.js,现有 originGuard / tokenGuard 自动生效。

7.1 上传

POST /api/ops/upload

{ "fileName": "店铺运营数据.xlsx", "contentBase64": "UEsDBBQ…", "year": 2026, "force": false }
  • contentBase64:文件内容 base64(接受去掉 data:...;base64, 前缀)
  • year:缺省当前年
  • force=false 且 sha256 已存在 → 409 + 已存在的 uploadId

成功 200:

{
  "ok": true,
  "uploadId": "20261020-153012-a1b2c3",
  "report": {
    "rows": 1044, "shops": 36, "overwritten": 0, "warningCount": 183,
    "dates": { "from": "20260909", "to": "20261007" }
  },
  "warnings": [
    { "row": 4, "column": "9.14进店转换率", "raw": "10..6%", "reason": "malformed-percent" }
  ]
}

warnings 最多返回前 200 条,全量在 report 里;warningCount 是全量计数。

失败码:400(格式/内容不合法)、409(重复)、413(>10MB)、503(存储不可用)、500(写入失败)。

7.2 概览

GET /api/ops/summary

{
  "enabled": true,
  "driver": "sqlite",
  "schools": [{ "name": "浙江工商大学桐庐校区", "shopCount": 9 }],
  "shops": [{ "key": "1362222034", "shopName": "…", "school": "…", "status": "已上线" }],
  "coverage": { "from": "20260909", "to": "20261007" },
  "uploadCount": 1,
  "rowCount": 1044,
  "lastUpload": { "id": "…", "at": "…", "originalName": "…", "rows": 1044, "warningCount": 183 }
}
  • 存储未配置/不可用:200 + { "enabled": false, "reason": "storage-not-configured" | "storage-unreachable", "detail": "脱敏信息" }(页面据此渲染引导态)
  • 空库:200,数组为空、coverage: null

7.3 矩阵查询

GET /api/ops/rows?from=20260909&to=20261007&schools=浙江科技学院&shops=1362222034&metrics=order_cnt,revenue

  • from / to:YYYYMMDD,缺省取全区间
  • schools / shops:逗号分隔,缺省全选
  • metrics:缺省 4 项全量

响应:

{
  "from": "20260909", "to": "20261007",
  "dates": ["20260909", "…"],
  "metrics": ["order_cnt", "revenue", "exposure", "click_rate"],
  "groups": [
    {
      "school": "浙江工商大学桐庐校区",
      "shops": [
        {
          "key": "1362222034", "shopName": "…", "status": "已上线",
          "series": { "order_cnt": { "20260909": 0, "20260910": 5 }, "revenue": { "…": 58.1 } },
          "quality": { "20260914": ["percent-scaled"] },
          "totals": { "order_cnt": 1234, "revenue": 23456.7 }
        }
      ]
    }
  ]
}

totals 只对可加指标(订单量、营收、曝光量)计算;比率由前端按权重算。

7.4 导入记录与原件

  • GET /api/ops/uploads → { uploads: [{ id, originalName, uploadedAt, rows, shops, warningCount, parserKind, sizeBytes }] },按时间倒序,不回传 content
  • GET /api/ops/uploads/:id/file → 下载原件:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,Content-Disposition: attachment; filename*=UTF-8''<encoded>
    • id 必须匹配 ^\d{8}-\d{6}-[a-z0-9]{6}$,否则 400

7.5 重新解析

POST /api/ops/uploads/:id/reparse → 读原件 → 重新解析 → 事务内覆盖该上传产生的行 → 更新 report,返回新报告。id 与原文件不变。

7.6 导出

GET /api/ops/export?…(参数同 rows),CSV、UTF-8 BOM:

  • 只勾一个指标 → 矩阵格式(行=店铺、列=日期)
  • 勾多个指标 → 长表堆叠(学校,店铺,日期,指标,数值),便于透视

7.7 不提供的端点

  • 删除上传:本期不做(ON DELETE CASCADE 已为将来留好)
  • 编辑单元格:不做,人工表以原件为准

8. 界面

8.1 入口

侧边栏「经营分析」组新增一级菜单 店铺运营,位于「数据采集」之后、「数据中台」之前。视图标识 ops,沿用现有 data-view 机制。

8.2 工具条

[⬆ 上传文件]  学校 ▾(全选)  日期 [09-09]~[10-07]  指标 ☑订单量 ☑营收 ☑曝光量 ☑进店转化率  [报表|卡片]  [导出 CSV]
  • 学校、指标多选,交互复用现有 scan-dropdown-menu 组件样式
  • 指标勾选存 localStorage(ops.metrics),默认 4 项全选
  • 视图切换存 localStorage(ops.view),默认「报表」

8.3 报表视图

每个勾选指标渲染一个区块,区块内一张矩阵:

  • 表头两行:第一行学校分组(跨列),第二行日期(9.9 格式,与人工表一致)
  • 首列冻结显示店铺名
  • 单元格:数值;NULL 显示 –;quality 非空的格子右上角 ⚠,title 显示原文与原因
  • 比率显示为百分比(0.047 → 4.7%),1 位小数
  • 金额 2 位小数 + 千分位
  • 每个学校分组末尾一行小计(数量/金额求和;比率按曝光量或订单量加权,权重为 0 时显示 –)
  • 日期列多时横向滚动,店铺列 position: sticky

8.4 卡片视图

  • 每店一张卡:店名、学校、状态徽标
  • 卡内按勾选指标显示区间合计(或加权均值)+ 迷你折线(SVG,无依赖)
  • 点卡片展开逐日明细
  • 按学校分组,组头可折叠

8.5 上传弹窗

  1. 拖拽区 / 选择文件(accept=".xlsx")
  2. 年份输入(默认当前年),旁注"文件里的日期没有年份,请确认"
  3. 点「开始导入」→ 转 base64 调上传接口
  4. 结果区:行数 / 店铺数 / 日期范围 / 覆盖条数 / 警告列表(前 20 条 + 展开全部)
  5. 409 时提示「该文件已导入过」并提供「仍然导入」(force=true)
  6. 503 时提示「存储不可用」并给重试按钮

不做"先预览再导入"的两步流程:解析在服务端,导入即入库,结果以报告呈现;不满意可用原件重新解析覆盖。

8.6 导入记录

右上角「导入记录」抽屉:文件名、上传时间、行数、警告数、下载原件、重新解析。

8.7 空态与错误

  • 存储未配置/不可用:整页引导(SQLite 一般是文件权限问题;PG 提示填连接串)+ 重试按钮
  • 空库:上传引导,不渲染空表格
  • 查询失败:表格区显示重试
  • 所有降级都只影响本菜单,其它功能不受影响

9. 错误处理与边界

场景 行为
非 zip / 非 xlsx 400「文件不是标准 xlsx」,不入库
找不到数据表 400「未找到数据表」
表头不匹配任何解析器 400「暂不支持该表格格式」
文件 > 10MB 413
base64 解码失败 400
SQLite 文件不可写 ops 503;summary enabled:false, reason:"storage-unreachable"
PG 未配置连接串 ops 503;summary enabled:false, reason:"storage-not-configured"
node:sqlite 不可用(Node < 22.5) ops 503;summary 带 reason:"sqlite-unavailable"
写入事务失败 回滚,500,无半成品数据
同一文件重复上传 409 + 已有 uploadId(force=true 继续)
行级解析错误 不中断,进 warnings
并发上传 每批独立事务 + ON CONFLICT 幂等;SQLite 用 WAL + busy_timeout

10. 安全

  • uploadId 服务端生成,客户端不可指定
  • 下载端点用正则校验 id 后再查库(不拼路径)
  • 原文件名去掉路径分隔符与控制字符,长度截断 120
  • 大小上限 10MB(MAX_BODY_BYTES 32MB,base64 后约 13.3MB,仍在限内)
  • 不解析宏、不外链、不执行单元格内容
  • 连接串只存 config.json(gitignore);日志与响应一律脱敏
  • SQL 全部参数化,不拼接用户输入
  • SQLite 文件权限沿用系统默认,位于项目 data/(已 gitignore)

11. 测试

11.1 解析器单测(scripts/test-ops-import.js,npm run test:ops)

fixture:scripts/fixtures/ops-manual-sample.xlsx,脱敏样例(店名改 样例店 01,数值按原形态保留),覆盖全部边界值:

用例 断言
表头识别 返回 manual-ops,29 天 × 4 指标
日期解析 9.9 + year 2026 → 20260909;10.7 → 20261007
脏值 10..6% click_rate=0.106,quality 含 malformed-percent
脏值 12.3 click_rate=0.123,quality 含 percent-scaled
文本 未开放 NULL,quality 含 non-numeric,raw 保留原文
空单元格 NULL + missing,与 0 可区分
占位行 不产生行
无 ID 店铺 落库,`shop_key = school
行数 36 店 × 29 天 = 1044

11.2 端到端测试(同脚本,临时 SQLite,不依赖外部环境)

用 os.tmpdir() 下的临时库文件,跑完删除;起临时服务(复用 smoke 的随机端口方式):

  • 空态:GET /api/ops/summary → 200,enabled:true,数组为空
  • 上传:200,返回 uploadId;ops_upload.content 字节 sha256 与请求一致
  • 重复上传:409;force=true → 200
  • 查询:行数/店铺数/日期范围正确;schools、metrics 过滤生效
  • 下载:字节与原文件一致
  • 非法 id:400
  • 重新解析:200,行数与 upload_id 不变
  • 事务性:构造写入失败 → 500,ops_upload 无残留
  • 降级:把库文件设为不可写 → summary enabled:false,其它菜单接口仍 200

PG 相关用例用 OPS_TEST_DATABASE_URL,未设置则跳过并打印原因(本期不装 pg)。

11.3 smoke 扩展

scripts/smoke.js 增加只读断言:GET /api/ops/summary 200、GET /api/ops/uploads 200、GET /api/ops/rows 200。不新增写操作,保持 smoke「只读 + 无副作用」边界。

11.4 手工验收

上传真实文件 → 切指标/学校/日期 → 报表与卡片 → CSV 用 Excel 打开 → 重传同文件见 409 → 覆盖导入后数值更新且旧原件仍在记录里 → 重启服务后数据仍在。

12. 对现有功能的影响

功能 影响 说明
数据采集 / 同步 / 诊断 无 只读 data/reports/
报表 / 趋势图表 / 问数 无 各自读 data/reports/,不感知 ops 数据
钉钉 AI 表格推送 无 推送范围仍是 lake 行
任务中心 无 导入是同步操作,不注册任务
服务启动 低风险 存储惰性初始化,失败不阻塞启动
Node 版本 有要求 SQLite 驱动需 Node ≥ 22.5;PG 驱动不受限。README 注明
打包分发 无变化 SQLite 是本地文件,安装包不需要外部依赖
备份 小改 data/ops.db 随 data/ 一起备份(README 补一句)

13. 后续扩展(不在本次范围)

  1. 切换云端 PG:按 §5.6 操作;pg 依赖届时再装
  2. 多租户:若将来分发给不同客户,三张表需加 tenant_id 并进主键。本期单租户
  3. 平台导出导入:新增 parser.kind = 'platform-export',124 列 → 现有 lake 行结构
  4. 采集 vs 人工对照:用 shopId + date 关联,同指标两值并列,差异超阈值标红
  5. 人工数据并入问数:作为独立数据源,避免与采集口径混淆
  6. 删除/回滚上传:按 uploadId 删除(ON DELETE CASCADE 已就绪)

14. 待办清单(供实施计划拆解)

  • server/services/xlsx.js:ZIP/XML 读取器
  • ops-import.js:解析器注册表 + manual-ops 解析器
  • ops-store.js:驱动选择 + 统一接口 + 迁移
  • server/stores/ops-sqlite.js:node:sqlite 实现
  • server/stores/ops-pg.js:PG 实现(懒加载 pg,未安装时明确报错)
  • server/routes/ops.js:上传/概览/查询/记录/下载/重解析/导出 + 降级
  • server/routes/index.js 注册 ops
  • server/context.js:读取 storage 配置与默认值合并
  • public/ops.js + index.html 页面与菜单 + style.css
  • scripts/db-init.js + scripts/db-export.js / db-import.js
  • scripts/test-ops-import.js + 脱敏 fixture
  • scripts/smoke.js 只读断言
  • README:「店铺运营数据导入」章节 + Node 版本说明 + 备份说明