# 店铺运营数据上传 · 设计文档 - 日期:2026-10-20(SQLite 先行 / PG 可切换版) - 分支:`dev` - 状态:待评审 ## 1. 背景 现有经营数据只有一个入口:从本机淘宝闪购商家客户端按天采集([napos-service.js](../../../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`(模板已加好): ```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 统一接口 ```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(), // 学校/店铺/覆盖区间/计数 } ``` SQL 只在两个 driver 内部出现;`ops-import.js` 与路由层不感知方言。 ### 5.3 表结构 SQLite 版(主): ```sql 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 个占位行) - 整行所有指标均为空 保留但标记: - 店铺 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. 正则提取 `` / `` / `` / ``;类型 `s` 查共享字符串表,无 `` 视为空 4. 合并单元格按左上角取值,其余为空(本格式用不到合并) 5. 日期单元格若为 Excel 序列号且列名疑似日期,按 `1899-12-30` 基准换算(本格式日期是字符串,不触发) 不做:公式计算(读缓存值)、样式、多 sheet 遍历(只读第一个非空 sheet)、`.xls` 二进制。 解析异常 → 400「文件可能不是标准 xlsx」,不入库。 ## 7. 接口 所有端点挂在 `server/routes/ops.js`,现有 `originGuard` / `tokenGuard` 自动生效。 ### 7.1 上传 `POST /api/ops/upload` ```json { "fileName": "店铺运营数据.xlsx", "contentBase64": "UEsDBBQ…", "year": 2026, "force": false } ``` - `contentBase64`:文件内容 base64(接受去掉 `data:...;base64,` 前缀) - `year`:缺省当前年 - `force=false` 且 sha256 已存在 → 409 + 已存在的 `uploadId` 成功 200: ```json { "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` ```json { "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 项全量 响应: ```json { "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''` - `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|shopName`,`quality` 含 `no-shop-id` | | 行数 | 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 版本说明 + 备份说明