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 人工」差异视图留路。
程序在本机跑,数据库在云端 PostgreSQL。源数据(原件 + 解析行)都进云端库,本机不保留业务数据副本。这样换一台电脑装同一套程序即可看到同一份数据。
店铺运营数据.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 事务写入: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) }]),后续加平台导出格式时只追加一项,不动存储与界面。
config.json 新增(已加好模板,connectionString 留空表示未配置):
"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 生效,默认 publicOPS_DATABASE_URL 覆盖 connectionString(部署与测试用)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、negativeCREATE TABLE IF NOT EXISTS,进程内只执行一次(memo promise);另提供 npm run db:init 显式执行BEGININSERT INTO ops_upload (...) RETURNING id(含 content bytea)INSERT INTO ops_row ... ON CONFLICT (date, shop_key) DO UPDATE SET ...(每批 200 行)INSERT INTO ops_shop ... ON CONFLICT (key) DO UPDATE SET ...COMMIT任一步失败 → 回滚,本次上传不产生任何残留(不存在"原件已存、行没写"的半成品)。
ON CONFLICT DO UPDATE 覆盖 4 个指标、raw、upload_id,返回 overwritten 计数ops_upload 行都保留(含原件),历史可追溯content → 重新生成行 → 同一事务内 upsert → 更新 ops_upload.report;id 与 content 不变表头同时出现下列两类以上即判定为人工统计表:
识别失败 → 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,属正常结构):
保留但标记:
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 条,全量在 ops_upload.report 里;warningCount 是全量计数。
失败码:400(格式/内容不合法)、409(重复)、413(>10MB)、503(数据库不可用)、500(写入失败)。
GET /api/ops/summary
{
"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 }
}
{ "enabled": false, "reason": "postgres-not-configured" }(页面据此渲染引导态){ "enabled": false, "reason": "postgres-unreachable", "error": "脱敏后的错误" }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 只对可加指标(订单量、营收、曝光量)计算;比率为空,由前端按权重算。
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}$,否则 400(杜绝注入与路径问题)POST /api/ops/uploads/:id/reparse → 读 content → 重新解析 → 事务内覆盖该上传产生的行 → 更新 report,返回新报告。id 与原文件不变。
GET /api/ops/export?…(参数同 rows),CSV、UTF-8 BOM:
学校,店铺,日期,指标,数值),便于透视ops_upload 上的 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 |
connectionString 为空 |
ops 接口 503;summary 返回 enabled:false |
| 连接超时 / 认证失败 | 503,错误信息脱敏后返回并记日志 |
| 写入事务失败 | 回滚,500,无半成品数据 |
| 同一文件重复上传 | 409 + 已有 uploadId(force=true 继续) |
| 行级解析错误 | 不中断,进 warnings |
| 并发上传 | 每批独立事务,ON CONFLICT 保证幂等,不产生重复行 |
| 连接池耗尽 | pg 排队;connectTimeoutMs 超时返回 503 |
uploadId 服务端生成,客户端不可指定MAX_BODY_BYTES 32MB,base64 后约 13.3MB,仍在限内)config.json(gitignore);日志与接口响应一律脱敏(postgres://user:***@host)ssl: true),云端强制加密pg 占位符),不拼接字符串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 |
用 OPS_TEST_DATABASE_URL 指向独立的测试库或独立 schema(脚本内建 ops_test_<随机> schema,跑完 drop),未设置则跳过集成用例并打印跳过原因:
GET /api/ops/summary → 200uploadId;ops_upload.content 的 sha256 与请求一致force=true → 200schools、metrics 过滤生效upload_id 不变ops_upload 无残留scripts/smoke.js 增加只读断言:GET /api/ops/summary 200、GET /api/ops/uploads 200(或 503 当未配置)、GET /api/ops/rows 200。不新增写操作,保持 smoke「只读 + 无副作用」边界。
上传真实文件 → 切指标/学校/日期 → 报表与卡片 → CSV 用 Excel 打开 → 重传同文件见 409 → 覆盖导入后数值更新且旧原件仍在记录里 → 断开网络后页面显示数据库不可用、其它菜单正常。
| 功能 | 影响 | 说明 |
|---|---|---|
| 数据采集 / 同步 / 诊断 | 无 | 只读 data/reports/ |
| 报表 / 趋势图表 / 问数 | 无 | 各自读 data/reports/,不感知 ops 数据 |
| 钉钉 AI 表格推送 | 无 | 推送范围仍是 lake 行 |
| 任务中心 | 无 | 导入是同步操作,不注册任务 |
| 服务启动 | 低风险 | 连接池惰性建立,连不上不阻塞启动 |
| 系统设置 | 小改 | 新增「云数据库」状态展示(连通/未配置),连接串本身可在 config.json 手填 |
| 打包分发 | 变重 | 安装包现在依赖云端 PG;需在 README 写明"导入功能需要数据库" |
| 备份 | 转移 | 数据在云端,由数据库备份策略负责;本地不再需要备份 ops 数据 |
ops_upload / ops_row / ops_shop 需加 tenant_id 并进主键,连接串按租户隔离或共库分列。本期假设单租户(同一团队共用一套数据)。parser.kind = 'platform-export',124 列 → 现有 lake 行结构;届时需回答公司/连锁汇总行是合成还是省略shopId + date 关联,同指标两值并列,差异超阈值标红uploadId 删除(ON DELETE CASCADE 已就绪)npm i pg(唯一新依赖)+ package.json 脚本 db:init / test:opsserver/services/xlsx.js:ZIP/XML 读取器ops-import.js:解析器注册表 + manual-ops 解析器ops-store.js:连接池、建表、事务写入、查询、脱敏日志server/routes/ops.js:上传/概览/查询/记录/下载/重解析/导出 + 降级server/routes/index.js 注册 opsconfig.json postgres 段读取与默认值合并(server/context.js)public/ops.js + index.html 页面与菜单 + style.cssscripts/db-init.js:显式建表scripts/test-ops-import.js + 脱敏 fixturescripts/smoke.js 只读断言