# 店铺运营数据上传 · 实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 让用户上传商家后台/人工统计的 `店铺运营数据.xlsx`,原件入库、解析成结构化行,并在新增「店铺运营」页按指标矩阵/卡片查看。 **Architecture:** 上传走 `POST /api/ops/upload`(base64)→ `ops-import.js` 识别并清洗 → `ops-store.js` 统一接口 → 本地 SQLite(`node:sqlite`,`data/ops.db`)。云端 PG 驱动留同接口的壳,本期不装依赖。前端 `public/ops.js` 独立视图,复用现有 `.view` / `.btn` / 表格样式。 **Tech Stack:** Node 22.23(内置 `zlib` / `node:sqlite`)、原生前端 JS、零新增依赖。 ## Global Constraints - Node ≥ 22.5(`node:sqlite` 要求);低于此版本只有「店铺运营」不可用,其余功能照常 - 本期不新增 npm 依赖;`pg` 只在 PG 驱动里懒加载,未安装时给出明确错误 - 数据文件:`data/ops.db`(`data/` 已在 `.gitignore`) - 所有新接口前缀 `/api/ops/`,沿用 `originGuard` / `tokenGuard` - 界面文案中文,样式复用现有 `.page-header` / `.btn` / `.card` / `table` - 每完成一个任务提交一次,提交信息用 `feat: ...` / `test: ...` ## 文件结构 | 文件 | 责任 | |---|---| | `server/services/xlsx.js` | ZIP+XML → `{ sheets: [{ name, rows }] }`,纯函数 | | `ops-import.js` | 解析器注册表;`manual-ops` 识别/解析/清洗;输出行与警告 | | `server/stores/ops-sqlite.js` | SQLite 实现:建表、事务、upsert、查询、原件读写 | | `server/stores/ops-pg.js` | PG 实现壳(懒加载 `pg`),本期不启用 | | `ops-store.js` | 读 `config.storage`、选驱动、暴露统一接口与降级状态 | | `server/routes/ops.js` | 7 个端点 + 降级响应 | | `public/ops.js` | 页面:工具条、矩阵、卡片、上传弹窗、导入记录 | | `scripts/test-ops-import.js` | 解析器单测 + 端到端接口测试(临时库) | | `scripts/db-init.js` / `db-export.js` / `db-import.js` | 建表与数据搬迁 | | `scripts/fixtures/ops-manual-sample.xlsx` | 脱敏样例(36 店 × 29 天) | ## Task 1: xlsx 读取器 **Files:** Create `server/services/xlsx.js`; Test `scripts/test-ops-import.js`(先建最小用例) **Interfaces:** - Produces: `readXlsx(buffer) → { sheets: [{ name, rows: (string|number|null)[][] }] }`,`rows[0]` 为第一行;失败抛 `Error` 且 `err.code = 'XLSX_PARSE_FAILED'` - [ ] 写失败用例:`readXlsx(Buffer.from('not a zip'))` 应抛 `XLSX_PARSE_FAILED` - [ ] 跑测试确认失败 - [ ] 实现:定位 ZIP EOCD(`0x06054b50`)→ 中央目录 → 取 `xl/workbook.xml`、第一个 sheet、`xl/sharedStrings.xml` → `zlib.inflateRawSync`(stored 直接切片)→ 正则取 ``、``、`` → 共享字符串按索引取 → 按列字母换算列号补空 → 返回二维数组 - [ ] 用真实文件 `c = readXlsx(fs.readFileSync('/店铺运营数据.xlsx'))` 断言 `rows.length === 43`、`rows[0].length === 122`、`rows[1][3] === '千里香馄饨(康诺餐厅店)'` - [ ] 提交 `feat: xlsx 读取器(零依赖)` ## Task 2: 人工统计表解析器 **Files:** Create `ops-import.js`; Create `scripts/fixtures/ops-manual-sample.xlsx`(用 python openpyxl 脱敏生成:店名 `样例店 NN`、学校保留 3 个、数值按原形态含 `10..6%`/`12.3`/`未开放`/空);Test 扩展 `scripts/test-ops-import.js` **Interfaces:** - Produces: `detect(cells) → 'manual-ops' | null`;`importManualOps(cells, { year }) → { rows, shops, warnings, dates }` - `rows[i] = { date:'YYYYMMDD', shopKey, shopId, shopName, school, status, orderCnt, revenue, exposure, clickRate, raw:{...}, quality:[...] }` - `warnings[i] = { row, column, raw, reason }` - [ ] 写用例:表头识别(`9.9订单量` 等)→ `'manual-ops'`;`9.9`+2026 → `20260909`;`10..6%` → `0.106` + `malformed-percent`;`12.3` → `0.123` + `percent-scaled`;`未开放` → `null` + `non-numeric`;空 → `null` + `missing`;占位行(无店名)不产生行;无 ID 店 `shopKey = school|shopName` + `no-shop-id`;总行数 = 店数 × 29 - [ ] 跑测试确认失败 - [ ] 实现 `detect`(表头含「订单量/营收/曝光量」≥2 且含「转换率」)、`parseHeader`(按名定位,容忍空格)、`parseDate`(月.日 + year,跨年 +1)、`clean`(六步清洗)、`buildRows`(跳过空店名/全空行) - [ ] 跑测试确认通过 - [ ] 提交 `feat: 人工统计表解析器` ## Task 3: 存储层(SQLite + 统一接口) **Files:** Create `server/stores/ops-sqlite.js`、`ops-store.js`、`server/stores/ops-pg.js`;Modify `server/context.js`(`storage` 默认值) **Interfaces:** - `createStore(config, { rootDir }) → { kind, init(), health(), withTransaction(fn), insertUpload(tx, upload), upsertRows(tx, rows), upsertShops(tx, shops), findUploadBySha(sha), listUploads(), getUpload(id), getContent(id), updateReport(id, report), queryRows({from,to,schools,shops,metrics}), summary() }` - `upload = { id, originalName, sha256, sizeBytes, uploadedAt, year, parserKind, parserVersion, report, content:Buffer }` - `init()` 幂等;`health() → { ok, kind, detail }` - [ ] 写用例(临时库):`init()` 两次不报错;事务内写 upload+rows 后 `queryRows` 可读;`ON CONFLICT` 覆盖同 `date|shop_key`;事务抛错后无残留 - [ ] 实现 `ops-sqlite.js`:`new DatabaseSync(file)`,`PRAGMA journal_mode=WAL`、`busy_timeout`、`foreign_keys=ON`;DDL 见设计文档 §5.3;`withTransaction` 用 `BEGIN/COMMIT/ROLLBACK` - [ ] `ops-store.js`:读 `config.storage`(默认 `sqlite`),懒加载驱动,缓存 `init` 的 promise;`kind`/`health` 暴露给路由;失败不抛到进程 - [ ] `ops-pg.js`:同接口,`require('pg')` 失败时 `health() = { ok:false, detail:'pg-not-installed' }` - [ ] 跑测试通过;提交 `feat: ops 存储层(SQLite 驱动 + PG 壳)` ## Task 4: HTTP 端点 **Files:** Create `server/routes/ops.js`;Modify `server/routes/index.js` **Interfaces:** - `POST /api/ops/upload {fileName, contentBase64, year, force}` → §7.1 - `GET /api/ops/summary`、`/api/ops/rows`、`/api/ops/uploads`、`/api/ops/uploads/:id/file`、`POST /api/ops/uploads/:id/reparse`、`GET /api/ops/export` - [ ] 写用例:空态 summary 200;上传 200 且 `content` sha256 一致;重复 409;`force` 覆盖;查询过滤;下载字节一致;非法 id 400;reparse 行数不变 - [ ] 实现:`readBody` 取 base64 → `Buffer.from(b64,'base64')`;校验大小/类型/sha256;`opsImport` → `store.withTransaction`;`sendJson` 统一错误码(400/409/413/503/500) - [ ] 降级:`store.health().ok === false` 时 ops 接口 503,`summary` 返回 `{enabled:false, reason, detail}` - [ ] 注册到 `server/routes/index.js`(`ops` 放在 `reports` 之后、`static` 之前) - [ ] 跑测试通过;提交 `feat: /api/ops 端点` ## Task 5: 前端页面 **Files:** Create `public/ops.js`;Modify `public/index.html`(菜单 + `viewOps`)、`public/style.css`(矩阵/卡片少量样式)、`public/app.js`(`views` 映射 + `shanOpsInit` 钩子) **Interfaces:** - `window.shanOpsInit()` 首次激活时拉 `summary`,渲染工具条与空态 - 指标键固定 `order_cnt|revenue|exposure|click_rate`,默认全选 - [ ] 菜单:`data-view="ops"`,放在「数据采集」后 - [ ] 页面骨架:`page-header` + 工具条(上传、学校、日期、指标、视图切换、导出)+ 内容区 + 上传弹窗 + 记录抽屉 - [ ] 矩阵渲染:按学校分组、指标分区块、首列 sticky、`quality` 角标 `⚠`(title 带原文) - [ ] 卡片渲染:区间合计 + SVG 迷你折线 - [ ] 上传:`` → FileReader base64 → POST → 报告;409 提示;503 提示 - [ ] 导出:拼查询串打开 `/api/ops/export?...` - [ ] 手测:上传真实文件,切指标/学校/日期,两种视图,导出 CSV - [ ] 提交 `feat: 店铺运营页面` ## Task 6: 工具脚本、smoke、文档 **Files:** Create `scripts/db-init.js`、`scripts/db-export.js`、`scripts/db-import.js`;Modify `scripts/smoke.js`、`package.json`(`test:ops`、`db:*`)、`README.md` - [ ] `db-init.js`:`createStore(config).init()` 后打印驱动与表 - [ ] `db-export.js`:SQLite → JSON(uploads 元数据 + rows + shops,不含 content 或含 base64 可选) - [ ] `db-import.js`:JSON → 目标驱动 - [ ] smoke 增加三个只读断言(summary/uploads/rows 均 200) - [ ] README 新增章节:功能说明、Node 版本要求、数据文件位置、备份、切 PG 步骤 - [ ] `npm run test:ops` 全绿;`npm run smoke` 全绿 - [ ] 提交 `feat: 店铺运营数据导入(脚本/冒烟/文档)` ## 验收 - `npm run test:ops`:解析器 + 端到端用例全过(临时库,不依赖网络) - `npm run smoke`:原有断言 + 新增 3 条全过 - 手工:上传 `店铺运营数据.xlsx` → 36 店 × 29 天 → 切换指标/学校 → 卡片与矩阵 → 导出 CSV → 重传 409 → 重启服务数据仍在