dev现有经营数据只有一个入口:从本机淘宝闪购商家客户端按天采集(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,全公司/连锁行由采集时单独取回。人工统计表有三个结构性冲突:
因此人工统计表独立存储、独立展示,不与采集数据合并。两者通过 shopId + date 可关联,为将来的「采集 vs 人工」差异视图留路。
数据进关系库,但驱动可切换:
data/ops.db),用 Node 22 内置 node:sqlite,不引入依赖,整个流程离线可跑通存储层抽象成统一接口(见 §5),两个实现互换,解析器与界面完全不知道下面是哪种库。
店铺运营数据.xlsx,原件永久保留(字节级不变,可下载回).xls、.csv、在线编辑单元格、多文件一次上传店铺运营数据.xlsx:
Sheet1 尺寸 A1:DR43(122 列 × 43 行),Sheet2 为空序号 | 序号 | 学校名称 | 店铺名称 | ID | 上线状态 | 9.9订单量 | 9.9营收 | 9.9曝光量 | 9.9进店转换率 | …,前两列都叫「序号」,第 1 列实际为空9.9 ~ 10.7,每组 4 列(订单量 / 营收 / 曝光量 / 进店转换率),共 116 列脏数据(真实存在,必须处理):
| 类型 | 实例 | 数量 |
|---|---|---|
| 转化率写成百分数字符串 | 10..6% |
1 |
| 转化率写成百分数数值 | 12.3、4.9(应为 0.123 / 0.049) |
2 |
| 非数值文本 | 未开放 |
1 |
| 转化率空白 | None |
179 |
浏览器
│ 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) }]),后续加平台导出格式时只追加一项,不动存储与界面。
config.json(模板已加好):
"storage": {
"driver": "sqlite",
"sqlite": { "file": "data/ops.db" },
"postgres": {
"connectionString": "",
"ssl": true,
"max": 5,
"schema": "public",
"connectTimeoutMs": 10000
}
}
driver:sqlite(默认)| postgresdriver=sqlite:文件不存在时自动创建;data/ 已 gitignoredriver=postgres 且 connectionString 为空 → ops 功能禁用(服务不受影响)OPS_DATABASE_URL 可覆盖 PG 连接串(部署与测试用)node:sqlite 需要 Node ≥ 22.5,缺失时 ops 禁用并返回明确原因(其它功能不受影响)// 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 与路由层不感知方言。
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。
BEGININSERT INTO ops_upload (...)INSERT INTO ops_row ... ON CONFLICT (date, shop_key) DO UPDATE SET ...(每批 200 行)INSERT INTO ops_shop ... ON CONFLICT (key) DO UPDATE SET ...COMMIT(失败则 ROLLBACK,本次上传不留残留)ON CONFLICT DO UPDATE 覆盖 4 个指标、raw、upload_id,返回 overwritten 计数ops_upload 行都保留(含原件),历史可追溯content → 重新生成行 → 同一事务内 upsert → 更新 report;id 与 content 不变config.json 改 driver: "postgres" 并填 connectionStringnpm run db:init 建表npm run db:export(SQLite → JSON)与 db:import(JSON → PG)搬一次;不需要则直接空库开始两个脚本在实施阶段一并提供(小工具,复用现有接口)。
表头同时出现下列两类以上即判定为人工统计表:
识别失败 → 400,返回支持的格式说明,不落库。
9.9订单量 → 月 9、日 9year(弹窗默认当前年),落库为 YYYYMMDD12.31 之后出现 1.1)按表头名称定位,不依赖列序号:
学校名称、店铺名称、ID、上线状态 按精确名匹配,容忍前后空格指标内部键与采集口径对齐,便于将来对照:
| 人工表列 | 内部键 | 采集对应字段 | 类型 |
|---|---|---|---|
| 订单量 | order_cnt |
validOrderCnt |
整数 |
| 营收 | revenue |
grossAmt |
数值(元) |
| 曝光量 | exposure |
expUv |
整数 |
| 进店转换率 | click_rate |
clkExpRate |
比率 0–1 |
清洗顺序(对每个单元格):
null / 空串 → NULL + missing% 或 % → 去掉符号,标记 pct-symbol10..6 → 10.6)→ 标记 malformed-percentNumber() 解析;失败且非空(未开放 等)→ NULL + non-numeric,原文进 rawpercent-scaledout-of-rangenegative跳过(不产生 warning,属正常结构):
有数据的店保留全部日期,当天四项皆空也留一行(值全为 NULL),矩阵显示「–」,
保证「店 × 天」结构完整(36 × 29 = 1044 行)。
保留但标记:
school|shopName 作 shop_key,quality 加 no-shop-idNULLserver/services/xlsx.js 只做一件事:把 xlsx 解码成二维单元格数组。
xlsx 是 ZIP 容器,实现路径:
xl/workbook.xml、xl/worksheets/sheet1.xml、xl/sharedStrings.xmlzlib.inflateRawSync 解压(store 方式直接切片)<row> / <c> / <v> / <is><t>;类型 s 查共享字符串表,无 <v> 视为空1899-12-30 基准换算(本格式日期是字符串,不触发)不做:公式计算(读缓存值)、样式、多 sheet 遍历(只读第一个非空 sheet)、.xls 二进制。
解析异常 → 400「文件可能不是标准 xlsx」,不入库。
所有端点挂在 server/routes/ops.js,现有 originGuard / tokenGuard 自动生效。
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(写入失败)。
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 }
}
{ "enabled": false, "reason": "storage-not-configured" | "storage-unreachable", "detail": "脱敏信息" }(页面据此渲染引导态)coverage: nullGET /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 只对可加指标(订单量、营收、曝光量)计算;比率由前端按权重算。
GET /api/ops/uploads → { uploads: [{ id, originalName, uploadedAt, rows, shops, warningCount, parserKind, sizeBytes }] },按时间倒序,不回传 contentGET /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}$,否则 400POST /api/ops/uploads/:id/reparse → 读原件 → 重新解析 → 事务内覆盖该上传产生的行 → 更新 report,返回新报告。id 与原文件不变。
GET /api/ops/export?…(参数同 rows),CSV、UTF-8 BOM:
学校,店铺,日期,指标,数值),便于透视ON DELETE CASCADE 已为将来留好)侧边栏「经营分析」组新增一级菜单 店铺运营,位于「数据采集」之后、「数据中台」之前。视图标识 ops,沿用现有 data-view 机制。
[⬆ 上传文件] 学校 ▾(全选) 日期 [09-09]~[10-07] 指标 ☑订单量 ☑营收 ☑曝光量 ☑进店转化率 [报表|卡片] [导出 CSV]
scan-dropdown-menu 组件样式localStorage(ops.metrics),默认 4 项全选localStorage(ops.view),默认「报表」每个勾选指标渲染一个区块,区块内一张矩阵:
9.9 格式,与人工表一致)NULL 显示 –;quality 非空的格子右上角 ⚠,title 显示原文与原因0.047 → 4.7%),1 位小数–)position: stickyaccept=".xlsx")force=true)不做"先预览再导入"的两步流程:解析在服务端,导入即入库,结果以报告呈现;不满意可用原件重新解析覆盖。
右上角「导入记录」抽屉:文件名、上传时间、行数、警告数、下载原件、重新解析。
| 场景 | 行为 |
|---|---|
| 非 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 |
uploadId 服务端生成,客户端不可指定MAX_BODY_BYTES 32MB,base64 后约 13.3MB,仍在限内)config.json(gitignore);日志与响应一律脱敏data/(已 gitignore)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 |
用 os.tmpdir() 下的临时库文件,跑完删除;起临时服务(复用 smoke 的随机端口方式):
GET /api/ops/summary → 200,enabled:true,数组为空uploadId;ops_upload.content 字节 sha256 与请求一致force=true → 200schools、metrics 过滤生效upload_id 不变ops_upload 无残留enabled:false,其它菜单接口仍 200PG 相关用例用 OPS_TEST_DATABASE_URL,未设置则跳过并打印原因(本期不装 pg)。
scripts/smoke.js 增加只读断言:GET /api/ops/summary 200、GET /api/ops/uploads 200、GET /api/ops/rows 200。不新增写操作,保持 smoke「只读 + 无副作用」边界。
上传真实文件 → 切指标/学校/日期 → 报表与卡片 → CSV 用 Excel 打开 → 重传同文件见 409 → 覆盖导入后数值更新且旧原件仍在记录里 → 重启服务后数据仍在。
| 功能 | 影响 | 说明 |
|---|---|---|
| 数据采集 / 同步 / 诊断 | 无 | 只读 data/reports/ |
| 报表 / 趋势图表 / 问数 | 无 | 各自读 data/reports/,不感知 ops 数据 |
| 钉钉 AI 表格推送 | 无 | 推送范围仍是 lake 行 |
| 任务中心 | 无 | 导入是同步操作,不注册任务 |
| 服务启动 | 低风险 | 存储惰性初始化,失败不阻塞启动 |
| Node 版本 | 有要求 | SQLite 驱动需 Node ≥ 22.5;PG 驱动不受限。README 注明 |
| 打包分发 | 无变化 | SQLite 是本地文件,安装包不需要外部依赖 |
| 备份 | 小改 | data/ops.db 随 data/ 一起备份(README 补一句) |
pg 依赖届时再装tenant_id 并进主键。本期单租户parser.kind = 'platform-export',124 列 → 现有 lake 行结构shopId + date 关联,同指标两值并列,差异超阈值标红uploadId 删除(ON DELETE CASCADE 已就绪)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 注册 opsserver/context.js:读取 storage 配置与默认值合并public/ops.js + index.html 页面与菜单 + style.cssscripts/db-init.js + scripts/db-export.js / db-import.jsscripts/test-ops-import.js + 脱敏 fixturescripts/smoke.js 只读断言