2026-10-20-manual-ops-import-design.md 24 KB

店铺运营数据上传 · 设计文档

  • 日期:2026-10-20(PG 版,替换原本地文件存储方案)
  • 分支:dev
  • 状态:待评审

1. 背景

现有经营数据只有一个入口:从本机淘宝闪购商家客户端按天采集(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 留空表示未配置):

"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 表结构

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. 正则提取 <row> / <c> / <v> / <is><t>;类型 s 查共享字符串表,无 <v> 视为空
  4. 合并单元格按左上角取值,其余为空(本格式用不到合并)
  5. 日期单元格若为 Excel 序列号且列名疑似日期,按 1899-12-30 基准换算(本格式日期是字符串,不触发)

不做:公式计算(读缓存值)、样式、多 sheet 遍历(只读第一个非空 sheet)、.xls 二进制。

解析异常 → 400「文件可能不是标准 xlsx」,不入库。

7. 接口

所有端点挂在 server/routes/ops.js,现有 originGuard / tokenGuard 自动生效。

7.1 上传

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(写入失败)。

7.2 概览

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 }
}
  • 数据库未配置: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 项全量

响应:

{
  "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''<encoded>
    • 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
行数 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:「店铺运营数据导入」章节 + 云数据库说明