فهرست منبع

docs: 存储层改为双驱动(本地 SQLite 先行 / 云端 PG 可切换)

liangzhao 2 هفته پیش
والد
کامیت
2cd9502fdb
1فایلهای تغییر یافته به همراه166 افزوده شده و 115 حذف شده
  1. 166 115
      docs/superpowers/specs/2026-10-20-manual-ops-import-design.md

+ 166 - 115
docs/superpowers/specs/2026-10-20-manual-ops-import-design.md

@@ -1,6 +1,6 @@
 # 店铺运营数据上传 · 设计文档
 
-- 日期:2026-10-20(PG 版,替换原本地文件存储方案)
+- 日期:2026-10-20(SQLite 先行 / PG 可切换版)
 - 分支:`dev`
 - 状态:待评审
 
@@ -22,7 +22,7 @@
 
 `data/reports/` 的组织维度是「主体 = 商家账号(merchantId)」,行键 `date|scope|chainId|shopId`,全公司/连锁行由采集时单独取回。人工统计表有三个结构性冲突:
 
-1. **跨主体**:一份文件含 3 个学校(浙江科技学院 17 店、杭州师范大学下沙校区 15 店、浙江工商大学桐庐校区 9 店,共 34 个有 ID 的店铺),不属于任何单一 merchantId
+1. **跨主体**:一份文件含 3 个学校(浙江科技学院 17 店、杭州师范大学下沙校区 15 店、浙江工商大学桐庐校区 9 店,共 36 家店,其中 34 家有 ID),不属于任何单一 merchantId
 2. **指标少**:只有订单量、营收、曝光量、进店转化率 4 项,另 100+ 列在采集中存在而此处缺失
 3. **口径人工**:数值由人从后台抄录,存在格式错误,可信度低于采集值
 
@@ -30,18 +30,24 @@
 
 ### 存储位置
 
-程序在本机跑,数据库在云端 PostgreSQL。源数据(原件 + 解析行)都进云端库,本机不保留业务数据副本。这样换一台电脑装同一套程序即可看到同一份数据。
+数据进关系库,但**驱动可切换**:
+
+- **本期(测试阶段)**:本地 SQLite(`data/ops.db`),用 Node 22 内置 `node:sqlite`,不引入依赖,整个流程离线可跑通
+- **上线阶段**:云端 PostgreSQL,同一套表和接口,只换驱动和连接串
+
+存储层抽象成统一接口(见 §5),两个实现互换,解析器与界面完全不知道下面是哪种库。
 
 ## 2. 目标与非目标
 
 ### 目标
 
 - 上传 `店铺运营数据.xlsx`,**原件永久保留**(字节级不变,可下载回)
-- 解析为规范行,写入云端 PG,可按店铺 / 学校 / 日期 / 指标查询
+- 解析为规范行,写入关系库,可按店铺 / 学校 / 日期 / 指标查询
 - 界面以「报表矩阵」或「卡片」展示,指标由用户勾选,默认展示人工表的 4 个字段
 - 解析问题不丢数据:失败的单元格保留原文并标记,可回看
 - 支持对原件**重新解析**(解析规则迭代后不需要重新上传)
-- 云端库不可用时,服务照常启动,仅「店铺运营」功能不可用
+- 本地 SQLite 跑通完整流程;切换云端 PG 只改配置,不改业务代码
+- 数据库不可用时服务照常启动,仅「店铺运营」功能不可用
 
 ### 非目标
 
@@ -80,14 +86,12 @@
 ops-import.js        格式识别 → 解析器 → 行级清洗 → 警告收集
   │
   ▼
-ops-store.js         事务写入:ops_upload(含原件 bytea)+ ops_row upsert + ops_shop upsert
-  │
-  ▼
-云端 PostgreSQL      ops_upload / ops_row / ops_shop / ops_meta
+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        报表矩阵 / 卡片视图
 ```
@@ -98,110 +102,151 @@ public/ops.js        报表矩阵 / 卡片视图
 |---|---|---|
 | `server/services/xlsx.js` | 读 xlsx:`Buffer → { sheets: [{ name, rows: Cell[][] }] }` | Node 内置 `zlib` |
 | `ops-import.js` | 解析器注册表 + 人工统计表解析器(识别、宽表转长表、清洗) | `xlsx.js` |
-| `ops-store.js` | 连接池、建表、上传/行/店铺读写、查询 | `pg` |
-| `server/routes/ops.js` | HTTP 端点 + 数据库未配置时的降级响应 | 上面三个 + `util.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. 数据模型(PostgreSQL)
+## 5. 存储层
 
-### 5.1 连接与配置
+### 5.1 配置
 
-`config.json` 新增(已加好模板,`connectionString` 留空表示未配置):
+`config.json`(模板已加好):
 
 ```json
-"postgres": {
-  "connectionString": "postgres://user:pass@host:5432/dbname?sslmode=require",
-  "ssl": true,
-  "max": 5,
-  "schema": "public",
-  "connectTimeoutMs": 10000
+"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 统一接口
+
+```js
+// 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(),                  // 学校/店铺/覆盖区间/计数
 }
 ```
 
-- 未配置 `connectionString`:`ops` 功能禁用,服务正常启动
-- 配置了但连不上:启动不阻塞,`ops` 接口返回 503;页面显示"数据库未连接"并提供重试
-- 连接串含密码,只存在于 `config.json`(已 gitignore),日志中一律脱敏
-- `schema` 通过连接时设置 `search_path` 生效,默认 `public`
-- 允许用环境变量 `OPS_DATABASE_URL` 覆盖 `connectionString`(部署与测试用)
+SQL 只在两个 driver 内部出现;`ops-import.js` 与路由层不感知方言。
 
-### 5.2 表结构
+### 5.3 表结构
+
+SQLite 版(主):
 
 ```sql
 CREATE TABLE IF NOT EXISTS ops_meta (
-  key   text PRIMARY KEY,
-  value text NOT NULL
+  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    timestamptz NOT NULL DEFAULT now(),
-  year           integer NOT NULL,
-  parser_kind    text NOT NULL,
-  parser_version integer NOT NULL,
-  report         jsonb NOT NULL DEFAULT '{}'::jsonb,
-  content        bytea NOT NULL                 -- 原件
+  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     timestamptz NOT NULL DEFAULT now(),
-  last_seen      timestamptz NOT NULL DEFAULT now()
+  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       char(8) 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    numeric(14,2),
-  exposure   integer,
-  click_rate numeric(8,6),
-  raw        jsonb NOT NULL DEFAULT '{}'::jsonb,
-  quality    text[] NOT NULL DEFAULT '{}',
-  updated_at timestamptz NOT NULL DEFAULT now(),
+  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[]` |
 
-- `NULL` 与 `0` 严格区分:`0` 表示确实没有,`NULL` 表示没填/填错
-- `raw` 保留每个指标的原始文本,供回看与重新解析比对
-- `quality` 取值:`malformed-percent`、`percent-scaled`、`pct-symbol`、`non-numeric`、`missing`、`no-shop-id`、`out-of-range`、`negative`
-- 汇总值不落库,查询时计算
-- 建表用 `CREATE TABLE IF NOT EXISTS`,进程内只执行一次(memo promise);另提供 `npm run db:init` 显式执行
+SQLite 连接参数:`journal_mode=WAL`、`busy_timeout=5000`、`foreign_keys=ON`。
 
-### 5.3 写入流程(单事务)
+### 5.4 写入流程(单事务)
 
 1. `BEGIN`
-2. `INSERT INTO ops_upload (...) RETURNING id`(含 `content` bytea)
+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`
-
-任一步失败 → 回滚,本次上传不产生任何残留(不存在"原件已存、行没写"的半成品)。
+5. `COMMIT`(失败则 `ROLLBACK`,本次上传不留残留)
 
-### 5.4 覆盖与版本
+### 5.5 覆盖与版本
 
 - 同日同店再次导入 → `ON CONFLICT DO UPDATE` 覆盖 4 个指标、`raw`、`upload_id`,返回 `overwritten` 计数
 - 每次上传的 `ops_upload` 行都保留(含原件),历史可追溯
-- 重新解析:读 `content` → 重新生成行 → 同一事务内 upsert → 更新 `ops_upload.report`;`id` 与 `content` 不变
+- 重新解析:读 `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. 解析规则
 
@@ -311,9 +356,9 @@ xlsx 是 ZIP 容器,实现路径:
 }
 ```
 
-`warnings` 最多返回前 200 条,全量在 `ops_upload.report` 里;`warningCount` 是全量计数。
+`warnings` 最多返回前 200 条,全量在 `report` 里;`warningCount` 是全量计数。
 
-失败码:400(格式/内容不合法)、409(重复)、413(>10MB)、503(数据库不可用)、500(写入失败)。
+失败码:400(格式/内容不合法)、409(重复)、413(>10MB)、503(存储不可用)、500(写入失败)。
 
 ### 7.2 概览
 
@@ -322,6 +367,7 @@ xlsx 是 ZIP 容器,实现路径:
 ```json
 {
   "enabled": true,
+  "driver": "sqlite",
   "schools": [{ "name": "浙江工商大学桐庐校区", "shopCount": 9 }],
   "shops": [{ "key": "1362222034", "shopName": "…", "school": "…", "status": "已上线" }],
   "coverage": { "from": "20260909", "to": "20261007" },
@@ -331,8 +377,8 @@ xlsx 是 ZIP 容器,实现路径:
 }
 ```
 
-- 数据库未配置:200 + `{ "enabled": false, "reason": "postgres-not-configured" }`(页面据此渲染引导态)
-- 数据库配置了但连不上:200 + `{ "enabled": false, "reason": "postgres-unreachable", "error": "脱敏后的错误" }`
+- 存储未配置/不可用:200 + `{ "enabled": false, "reason": "storage-not-configured" | "storage-unreachable", "detail": "脱敏信息" }`(页面据此渲染引导态)
+- 空库:200,数组为空、`coverage: null`
 
 ### 7.3 矩阵查询
 
@@ -365,17 +411,17 @@ xlsx 是 ZIP 容器,实现路径:
 }
 ```
 
-`totals` 只对可加指标(订单量、营收、曝光量)计算;比率为空,由前端按权重算。
+`totals` 只对可加指标(订单量、营收、曝光量)计算;比率由前端按权重算。
 
 ### 7.4 导入记录与原件
 
-- `GET /api/ops/uploads` → `{ uploads: [{ id, originalName, uploadedAt, rows, shops, warningCount, parserKind, sizeBytes }] }`,按时间倒序。**不回传 `content`**
+- `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(杜绝注入与路径问题)
+  - `id` 必须匹配 `^\d{8}-\d{6}-[a-z0-9]{6}$`,否则 400
 
 ### 7.5 重新解析
 
-`POST /api/ops/uploads/:id/reparse` → 读 `content` → 重新解析 → 事务内覆盖该上传产生的行 → 更新 `report`,返回新报告。`id` 与原文件不变。
+`POST /api/ops/uploads/:id/reparse` → 读原件 → 重新解析 → 事务内覆盖该上传产生的行 → 更新 `report`,返回新报告。`id` 与原文件不变。
 
 ### 7.6 导出
 
@@ -386,7 +432,7 @@ xlsx 是 ZIP 容器,实现路径:
 
 ### 7.7 不提供的端点
 
-- 删除上传:本期不做(`ops_upload` 上的 `ON DELETE CASCADE` 已为将来留好)
+- 删除上传:本期不做(`ON DELETE CASCADE` 已为将来留好)
 - 编辑单元格:不做,人工表以原件为准
 
 ## 8. 界面
@@ -431,7 +477,7 @@ xlsx 是 ZIP 容器,实现路径:
 3. 点「开始导入」→ 转 base64 调上传接口
 4. 结果区:行数 / 店铺数 / 日期范围 / 覆盖条数 / 警告列表(前 20 条 + 展开全部)
 5. 409 时提示「该文件已导入过」并提供「仍然导入」(`force=true`)
-6. 503 时提示「云端数据库未连接」并给重试按钮
+6. 503 时提示「存储不可用」并给重试按钮
 
 不做"先预览再导入"的两步流程:解析在服务端,导入即入库,结果以报告呈现;不满意可用原件重新解析覆盖。
 
@@ -441,10 +487,10 @@ xlsx 是 ZIP 容器,实现路径:
 
 ### 8.7 空态与错误
 
-- 未配置数据库:整页引导「请在系统设置填写 PostgreSQL 连接串」
-- 已配置但连不上:错误提示 + 重试按钮,不影响其它菜单
+- 存储未配置/不可用:整页引导(SQLite 一般是文件权限问题;PG 提示填连接串)+ 重试按钮
 - 空库:上传引导,不渲染空表格
 - 查询失败:表格区显示重试
+- 所有降级都只影响本菜单,其它功能不受影响
 
 ## 9. 错误处理与边界
 
@@ -455,24 +501,24 @@ xlsx 是 ZIP 容器,实现路径:
 | 表头不匹配任何解析器 | 400「暂不支持该表格格式」 |
 | 文件 > 10MB | 413 |
 | base64 解码失败 | 400 |
-| `connectionString` 为空 | ops 接口 503;summary 返回 `enabled:false` |
-| 连接超时 / 认证失败 | 503,错误信息脱敏后返回并记日志 |
+| 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` 保证幂等,不产生重复行 |
-| 连接池耗尽 | `pg` 排队;`connectTimeoutMs` 超时返回 503 |
+| 并发上传 | 每批独立事务 + `ON CONFLICT` 幂等;SQLite 用 WAL + busy_timeout |
 
 ## 10. 安全
 
 - `uploadId` 服务端生成,客户端不可指定
-- 下载端点用正则校验 id 后再查库(不拼路径,天然无路径穿越)
+- 下载端点用正则校验 id 后再查库(不拼路径)
 - 原文件名去掉路径分隔符与控制字符,长度截断 120
 - 大小上限 10MB(`MAX_BODY_BYTES` 32MB,base64 后约 13.3MB,仍在限内)
 - 不解析宏、不外链、不执行单元格内容
-- 连接串只存 `config.json`(gitignore);日志与接口响应一律脱敏(`postgres://user:***@host`)
-- TLS 默认开启(`ssl: true`),云端强制加密
-- SQL 全部参数化(`pg` 占位符),不拼接字符串
+- 连接串只存 `config.json`(gitignore);日志与响应一律脱敏
+- SQL 全部参数化,不拼接用户输入
+- SQLite 文件权限沿用系统默认,位于项目 `data/`(已 gitignore)
 
 ## 11. 测试
 
@@ -492,26 +538,29 @@ fixture:`scripts/fixtures/ops-manual-sample.xlsx`,**脱敏样例**(店名
 | 无 ID 店铺 | 落库,`shop_key = school|shopName`,`quality` 含 `no-shop-id` |
 | 行数 | 36 店 × 29 天 = 1044 |
 
-### 11.2 接口测试(同脚本,需要数据库)
+### 11.2 端到端测试(同脚本,临时 SQLite,不依赖外部环境)
 
-用 `OPS_TEST_DATABASE_URL` 指向**独立的测试库或独立 schema**(脚本内建 `ops_test_<随机>` schema,跑完 drop),未设置则跳过集成用例并打印跳过原因:
+用 `os.tmpdir()` 下的临时库文件,跑完删除;起临时服务(复用 smoke 的随机端口方式):
 
-- 空态:`GET /api/ops/summary` → 200
-- 上传:200,返回 `uploadId`;`ops_upload.content` 的 sha256 与请求一致
+- 空态:`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` 无残留
+- 事务性:构造写入失败 → 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(或 503 当未配置)、`GET /api/ops/rows` 200。不新增写操作,保持 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 → 覆盖导入后数值更新且旧原件仍在记录里 → 断开网络后页面显示数据库不可用、其它菜单正常。
+上传真实文件 → 切指标/学校/日期 → 报表与卡片 → CSV 用 Excel 打开 → 重传同文件见 409 → 覆盖导入后数值更新且旧原件仍在记录里 → 重启服务后数据仍在。
 
 ## 12. 对现有功能的影响
 
@@ -521,30 +570,32 @@ fixture:`scripts/fixtures/ops-manual-sample.xlsx`,**脱敏样例**(店名
 | 报表 / 趋势图表 / 问数 | 无 | 各自读 `data/reports/`,不感知 ops 数据 |
 | 钉钉 AI 表格推送 | 无 | 推送范围仍是 lake 行 |
 | 任务中心 | 无 | 导入是同步操作,不注册任务 |
-| 服务启动 | 低风险 | 连接池惰性建立,连不上不阻塞启动 |
-| 系统设置 | 小改 | 新增「云数据库」状态展示(连通/未配置),连接串本身可在 config.json 手填 |
-| 打包分发 | 变重 | 安装包现在依赖云端 PG;需在 README 写明"导入功能需要数据库" |
-| 备份 | 转移 | 数据在云端,由数据库备份策略负责;本地不再需要备份 ops 数据 |
+| 服务启动 | 低风险 | 存储惰性初始化,失败不阻塞启动 |
+| Node 版本 | 有要求 | SQLite 驱动需 Node ≥ 22.5;PG 驱动不受限。README 注明 |
+| 打包分发 | 无变化 | SQLite 是本地文件,安装包不需要外部依赖 |
+| 备份 | 小改 | `data/ops.db` 随 `data/` 一起备份(README 补一句) |
 
 ## 13. 后续扩展(不在本次范围)
 
-1. **多租户**:若将来分发给不同客户,`ops_upload` / `ops_row` / `ops_shop` 需加 `tenant_id` 并进主键,连接串按租户隔离或共库分列。本期假设单租户(同一团队共用一套数据)。
-2. **平台导出导入**:新增 `parser.kind = 'platform-export'`,124 列 → 现有 lake 行结构;届时需回答公司/连锁汇总行是合成还是省略
-3. **采集 vs 人工对照**:用 `shopId + date` 关联,同指标两值并列,差异超阈值标红
-4. **人工数据并入问数**:作为独立数据源,避免与采集口径混淆
-5. **删除/回滚上传**:按 `uploadId` 删除(`ON DELETE CASCADE` 已就绪)
+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. 待办清单(供实施计划拆解)
 
-- [ ] `npm i pg`(唯一新依赖)+ `package.json` 脚本 `db:init` / `test:ops`
 - [ ] `server/services/xlsx.js`:ZIP/XML 读取器
 - [ ] `ops-import.js`:解析器注册表 + `manual-ops` 解析器
-- [ ] `ops-store.js`:连接池、建表、事务写入、查询、脱敏日志
+- [ ] `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`
-- [ ] `config.json` postgres 段读取与默认值合并(`server/context.js`)
+- [ ] `server/context.js`:读取 `storage` 配置与默认值合并
 - [ ] `public/ops.js` + `index.html` 页面与菜单 + `style.css`
-- [ ] `scripts/db-init.js`:显式建表
+- [ ] `scripts/db-init.js` + `scripts/db-export.js` / `db-import.js`
 - [ ] `scripts/test-ops-import.js` + 脱敏 fixture
 - [ ] `scripts/smoke.js` 只读断言
-- [ ] README:「店铺运营数据导入」章节 + 云数据库说明
+- [ ] README:「店铺运营数据导入」章节 + Node 版本说明 + 备份说明