# 店铺运营数据上传 · 设计文档 - 日期:2026-10-20(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 店,共 34 个有 ID 的店铺),不属于任何单一 merchantId 2. **指标少**:只有订单量、营收、曝光量、进店转化率 4 项,另 100+ 列在采集中存在而此处缺失 3. **口径人工**:数值由人从后台抄录,存在格式错误,可信度低于采集值 因此人工统计表**独立存储、独立展示**,不与采集数据合并。两者通过 `shopId + date` 可关联,为将来的「采集 vs 人工」差异视图留路。 ### 存储位置 程序在本机跑,数据库在云端 PostgreSQL。源数据(原件 + 解析行)都进云端库,本机不保留业务数据副本。这样换一台电脑装同一套程序即可看到同一份数据。 ## 2. 目标与非目标 ### 目标 - 上传 `店铺运营数据.xlsx`,**原件永久保留**(字节级不变,可下载回) - 解析为规范行,写入云端 PG,可按店铺 / 学校 / 日期 / 指标查询 - 界面以「报表矩阵」或「卡片」展示,指标由用户勾选,默认展示人工表的 4 个字段 - 解析问题不丢数据:失败的单元格保留原文并标记,可回看 - 支持对原件**重新解析**(解析规则迭代后不需要重新上传) - 云端库不可用时,服务照常启动,仅「店铺运营」功能不可用 ### 非目标 - 不做平台导出(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 事务写入:ops_upload(含原件 bytea)+ ops_row upsert + ops_shop upsert │ ▼ 云端 PostgreSQL ops_upload / ops_row / ops_shop / ops_meta │ ▼ 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` | 连接池、建表、上传/行/店铺读写、查询 | `pg` | | `server/routes/ops.js` | HTTP 端点 + 数据库未配置时的降级响应 | 上面三个 + `util.js` | | `public/ops.js` | 页面交互与渲染 | 无(原生 JS) | 解析器以注册表组织(`[{ kind, detect(header), parse(cells, opts) }]`),后续加平台导出格式时只追加一项,不动存储与界面。 ## 5. 数据模型(PostgreSQL) ### 5.1 连接与配置 `config.json` 新增(已加好模板,`connectionString` 留空表示未配置): ```json "postgres": { "connectionString": "postgres://user:pass@host:5432/dbname?sslmode=require", "ssl": true, "max": 5, "schema": "public", "connectTimeoutMs": 10000 } ``` - 未配置 `connectionString`:`ops` 功能禁用,服务正常启动 - 配置了但连不上:启动不阻塞,`ops` 接口返回 503;页面显示"数据库未连接"并提供重试 - 连接串含密码,只存在于 `config.json`(已 gitignore),日志中一律脱敏 - `schema` 通过连接时设置 `search_path` 生效,默认 `public` - 允许用环境变量 `OPS_DATABASE_URL` 覆盖 `connectionString`(部署与测试用) ### 5.2 表结构 ```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 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 -- 原件 ); 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() ); 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(), PRIMARY KEY (date, shop_key) ); CREATE INDEX IF NOT EXISTS ops_row_school_date_idx ON ops_row (school, date); ``` 设计要点: - `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` 显式执行 ### 5.3 写入流程(单事务) 1. `BEGIN` 2. `INSERT INTO ops_upload (...) RETURNING id`(含 `content` bytea) 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.4 覆盖与版本 - 同日同店再次导入 → `ON CONFLICT DO UPDATE` 覆盖 4 个指标、`raw`、`upload_id`,返回 `overwritten` 计数 - 每次上传的 `ops_upload` 行都保留(含原件),历史可追溯 - 重新解析:读 `content` → 重新生成行 → 同一事务内 upsert → 更新 `ops_upload.report`;`id` 与 `content` 不变 ## 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 条,全量在 `ops_upload.report` 里;`warningCount` 是全量计数。 失败码:400(格式/内容不合法)、409(重复)、413(>10MB)、503(数据库不可用)、500(写入失败)。 ### 7.2 概览 `GET /api/ops/summary` ```json { "enabled": true, "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": "postgres-not-configured" }`(页面据此渲染引导态) - 数据库配置了但连不上:200 + `{ "enabled": false, "reason": "postgres-unreachable", "error": "脱敏后的错误" }` ### 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` → 读 `content` → 重新解析 → 事务内覆盖该上传产生的行 → 更新 `report`,返回新报告。`id` 与原文件不变。 ### 7.6 导出 `GET /api/ops/export?…`(参数同 `rows`),CSV、UTF-8 BOM: - 只勾一个指标 → 矩阵格式(行=店铺、列=日期) - 勾多个指标 → 长表堆叠(`学校,店铺,日期,指标,数值`),便于透视 ### 7.7 不提供的端点 - 删除上传:本期不做(`ops_upload` 上的 `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 空态与错误 - 未配置数据库:整页引导「请在系统设置填写 PostgreSQL 连接串」 - 已配置但连不上:错误提示 + 重试按钮,不影响其它菜单 - 空库:上传引导,不渲染空表格 - 查询失败:表格区显示重试 ## 9. 错误处理与边界 | 场景 | 行为 | |---|---| | 非 zip / 非 xlsx | 400「文件不是标准 xlsx」,不入库 | | 找不到数据表 | 400「未找到数据表」 | | 表头不匹配任何解析器 | 400「暂不支持该表格格式」 | | 文件 > 10MB | 413 | | base64 解码失败 | 400 | | `connectionString` 为空 | ops 接口 503;summary 返回 `enabled:false` | | 连接超时 / 认证失败 | 503,错误信息脱敏后返回并记日志 | | 写入事务失败 | 回滚,500,无半成品数据 | | 同一文件重复上传 | 409 + 已有 uploadId(`force=true` 继续) | | 行级解析错误 | 不中断,进 `warnings` | | 并发上传 | 每批独立事务,`ON CONFLICT` 保证幂等,不产生重复行 | | 连接池耗尽 | `pg` 排队;`connectTimeoutMs` 超时返回 503 | ## 10. 安全 - `uploadId` 服务端生成,客户端不可指定 - 下载端点用正则校验 id 后再查库(不拼路径,天然无路径穿越) - 原文件名去掉路径分隔符与控制字符,长度截断 120 - 大小上限 10MB(`MAX_BODY_BYTES` 32MB,base64 后约 13.3MB,仍在限内) - 不解析宏、不外链、不执行单元格内容 - 连接串只存 `config.json`(gitignore);日志与接口响应一律脱敏(`postgres://user:***@host`) - TLS 默认开启(`ssl: true`),云端强制加密 - SQL 全部参数化(`pg` 占位符),不拼接字符串 ## 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 接口测试(同脚本,需要数据库) 用 `OPS_TEST_DATABASE_URL` 指向**独立的测试库或独立 schema**(脚本内建 `ops_test_<随机>` schema,跑完 drop),未设置则跳过集成用例并打印跳过原因: - 空态:`GET /api/ops/summary` → 200 - 上传:200,返回 `uploadId`;`ops_upload.content` 的 sha256 与请求一致 - 重复上传:409;`force=true` → 200 - 查询:行数/店铺数/日期范围正确;`schools`、`metrics` 过滤生效 - 下载:字节与原文件一致 - 非法 id:400 - 重新解析:200,行数与 `upload_id` 不变 - 事务性:构造写入失败(超长文本)→ 500,`ops_upload` 无残留 ### 11.3 smoke 扩展 `scripts/smoke.js` 增加只读断言:`GET /api/ops/summary` 200、`GET /api/ops/uploads` 200(或 503 当未配置)、`GET /api/ops/rows` 200。不新增写操作,保持 smoke「只读 + 无副作用」边界。 ### 11.4 手工验收 上传真实文件 → 切指标/学校/日期 → 报表与卡片 → CSV 用 Excel 打开 → 重传同文件见 409 → 覆盖导入后数值更新且旧原件仍在记录里 → 断开网络后页面显示数据库不可用、其它菜单正常。 ## 12. 对现有功能的影响 | 功能 | 影响 | 说明 | |---|---|---| | 数据采集 / 同步 / 诊断 | 无 | 只读 `data/reports/` | | 报表 / 趋势图表 / 问数 | 无 | 各自读 `data/reports/`,不感知 ops 数据 | | 钉钉 AI 表格推送 | 无 | 推送范围仍是 lake 行 | | 任务中心 | 无 | 导入是同步操作,不注册任务 | | 服务启动 | 低风险 | 连接池惰性建立,连不上不阻塞启动 | | 系统设置 | 小改 | 新增「云数据库」状态展示(连通/未配置),连接串本身可在 config.json 手填 | | 打包分发 | 变重 | 安装包现在依赖云端 PG;需在 README 写明"导入功能需要数据库" | | 备份 | 转移 | 数据在云端,由数据库备份策略负责;本地不再需要备份 ops 数据 | ## 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` 已就绪) ## 14. 待办清单(供实施计划拆解) - [ ] `npm i pg`(唯一新依赖)+ `package.json` 脚本 `db:init` / `test:ops` - [ ] `server/services/xlsx.js`:ZIP/XML 读取器 - [ ] `ops-import.js`:解析器注册表 + `manual-ops` 解析器 - [ ] `ops-store.js`:连接池、建表、事务写入、查询、脱敏日志 - [ ] `server/routes/ops.js`:上传/概览/查询/记录/下载/重解析/导出 + 降级 - [ ] `server/routes/index.js` 注册 `ops` - [ ] `config.json` postgres 段读取与默认值合并(`server/context.js`) - [ ] `public/ops.js` + `index.html` 页面与菜单 + `style.css` - [ ] `scripts/db-init.js`:显式建表 - [ ] `scripts/test-ops-import.js` + 脱敏 fixture - [ ] `scripts/smoke.js` 只读断言 - [ ] README:「店铺运营数据导入」章节 + 云数据库说明