# 店铺经营数据接口 v1（日数据模式）

## 基本约定

- 服务地址示例：`http://localhost:3000`。部署后替换为实际内部地址。
- 内部使用：**所有接口无需登录、Cookie、Authorization 或 API Key**。
- JSON 写入请求必须设置 `Content-Type: application/json`。
- 经营日期按北京时间（Asia/Shanghai）；日期格式 `YYYY-MM-DD`。
- `collectedAt` 为工具实际取到指标值的时间，必须包含时区，如 `2026-10-19T09:30:00+08:00`。
- 金额均为人民币元，不是分。推荐十进制字符串以保留精度；不能带千分位、货币符号或 `%`。
- 单次 1–500 条记录、请求体不超过 1 MiB；任何校验或写入失败，整批回滚。
- 页面 `/` 为看板；`/admin` 为无需验证的数据管理后台。
- 在线文档：[API 接入指南](/docs)；Markdown 原文：`GET /api/v1/docs?format=markdown`；机器可读规范：`GET /api/v1/openapi`。

## 1. 店铺列表与维护

### GET /api/v1/shops

响应 `200`：

```json
{"shops":[{"account":"rakuten-main","id":"256730481","name":"日系运动店"}]}
```

### PUT /api/v1/shops

创建或按店铺ID更新账号、店铺名称。店铺ID是唯一键，与账号无关；同一账号可以对应多家店铺，店铺名称也不要求唯一。店铺ID一旦关联数据，不应更改。

```json
{"account":"rakuten-main","id":"256730481","name":"日系运动店"}
```

响应 `200`：`{"shop":{"account":"rakuten-main","id":"256730481","name":"日系运动店"}}`。

三个字段均必填。`account` 为账号，1–200 字符；`id` 为店铺ID，1–100 位字母、数字、下划线、点或连字符；`name` 为店铺名称，1–200 字符。首尾空白不接受，账号和 ID 均传字符串以保留前导零。账号不是登录凭据，接口不接收密码。

迁移前已有店铺的账号可能返回 `null`，后台显示 `—`；可编辑或导入补齐。迁移保留原店铺ID和经营数据，不会生成虚构账号。

### DELETE /api/v1/shops?id=256730481

删除此店铺和它的所有经营数据。不存在返回 `404 NOT_FOUND`。已收录的采集请求历史保留，不会因重试旧请求恢复已删除数据。

批量删除使用 `DELETE /api/v1/shops`，设置 `Content-Type: application/json`，请求体为 `{"ids":["shop-a","shop-b"]}`。每次 1–500 项，重复 ID 自动去重，整批校验并在同一事务中删除；响应包含 `deletedShops` 实际删除店铺数。已不存在的店铺可安全重试。

### POST /api/v1/shops/import

批量新增或按店铺ID更新账号、店铺名称。后台「店铺管理 → Excel 导入店铺」也使用此接口。

```json
{
  "shops": [
    {"account": "rakuten-main", "id": "001234", "name": "运动旗舰店"},
    {"account": "rakuten-main", "id": "shop-002", "name": "生活优选店"}
  ]
}
```

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| shops | 是 | 1–1000 个店铺对象；同一批次不能重复 ID |
| shops[].account | 是 | 账号，1–200 字符；多个店铺可使用相同账号 |
| shops[].id | 是 | 字符串，规则同单店维护；文本 ID 保留前导零 |
| shops[].name | 是 | 1–200 字符，已有 ID 更新账号与名称 |

响应 `200`：

```json
{"total":2,"inserted":2,"updated":0}
```

整批在事务内写入；任一行不合法返回 `422`，不会部分导入。请求体最多 1 MiB。相同 ID 不会重复创建，重复提交会按当前库中已有 ID 统计为更新。

### Excel 表头匹配

后台支持 `.xlsx` 文件（最多 5 MiB、每批 1000 家店铺），可下载空白模板、选择工作表并预览。自动扫描前 30 行寻找表头，列顺序不限，忽略空格与大小写；多余列不导入。

| 目标字段 | 可识别表头 |
| --- | --- |
| 账号 | 账号、帐号、店铺账号、店铺帐号、account、shopAccount、storeAccount |
| 店铺 ID | 店铺 ID、店铺编号、店铺编码、店铺号、shopId、storeId、id |
| 店铺名称 | 店铺名称、店铺名、店名、shopName、storeName、name |

模板列顺序为 **账号、店铺id、店铺名称**，三列表头均需匹配。前导零或较长的账号、ID 必须在 Excel 中设为文本，避免数字精度损失。重复 ID（即使账号不同）、缺失字段或非法值会标出行号，修正全部错误后才能确认导入。旧版 `.xls` 请先另存为 `.xlsx`。

## 2. 分项／批量录入指标

### POST /api/v1/ingestion

```json
{
  "requestId":"455ff1f4-8250-4c71-9599-833a09a0c7b1",
  "source":"golden-eye-crawler",
  "records":[
    {
      "shopId":"256730481",
      "shopAccount":"rakuten-main",
      "shopName":"日系运动店",
      "period":{"grain":"day","startDate":"2026-10-18","endDate":"2026-10-18"},
      "collectedAt":"2026-10-19T09:30:00+08:00",
      "metrics":{"gmv":"12345.67"}
    }
  ]
}
```

| 字段 | 必填 | 说明 |
| --- | --- | --- |
| requestId | 是 | UUID；每个新批次生成一次。网络重试必须使用相同 ID 和原请求体 |
| source | 是 | 1–100 字符的来源名，例如 golden-eye-crawler |
| records | 是 | 1–500 条；允许混合店铺、不同日期及指标；只接收日数据 |
| shopId | 是 | 稳定店铺 ID，格式同店铺维护接口 |
| shopAccount | 新店铺需要 | 店铺账号，1–200 字符；已有店铺可省略，省略时保留原账号 |
| shopName | 新店铺需要 | 已有店铺可省略；提供时更新店铺名称；新店铺自动创建 |
| period | 是 | 周期对象，见下表 |
| collectedAt | 是 | 含时区的 ISO 8601 时间，最多毫秒；不得超出服务端当前时间 5 分钟 |
| metrics | 是 | 至少一个支持的指标；**省略表示不更新**，不是清空 |

未定义的字段会返回 422，避免拼写错误被静默忽略。

### 只导入日数据

写入及删除仅支持 `grain: "day"`，`startDate` 与 `endDate` 必须相同，年份支持 2000–2099。数据日期必须早于 `collectedAt` 对应的北京时间日期，不能录入当天或未来数据。

**原先导入 week / month / mtd 的请求现在返回 422。采集器只需导入每日五项指标，周、月、MTD 均自动计算，无需重复上传。** 修改或删除某天后，相关计算结果及环比立即随下一次查询重算，不保存独立的周/月/MTD 观测值，不读取旧快照。

### 周、月及 MTD 的计算口径

| 报表 | 计算范围 | 环比参照 |
| --- | --- | --- |
| day | 当天 | 上一自然日 |
| week | 周一至周日 | 上一周 |
| month | 自然月第一天至最后一天 | 上一自然月 |
| mtd | 月初至指定日期，本月默认到北京时间昨天 | 上月相同日期范围，超出上月天数时取上月月末 |

所有计算排除北京时间当天及未来数据，未结束的周/月只使用截至昨天的已有日记录。缺少日期不补零，金额和访客累加已有值；没有任何输入时返回空值。MTD 无上月对应数据时不显示环比，不回退到整月比较。环比分母为零时返回空值。

- 正向 = 累计成交金额，逆向 = 累计退款金额，GMV = 正向 − 逆向。
- 每日成交客户数 = 当日成交金额 / 当日客单价。
- 每日商品访客数 = 当日成交客户数 / 当日商品成交转化率。
- 周/月/MTD 客单价 = 累计成交金额 / 累计成交客户数。
- 周/月/MTD 商品成交转化率 = 累计成交客户数 / 累计商品访客数。
- 店铺访客数直接逐日累加，表示累计人次，不代表跨日去重人数。

推算人数保留小数，不平均每日比率，不用店铺访客数替代商品访客数，不用父单量替代成交客户数。任一天的必要输入不足或分母为零时，相应累计比率为空；成交金额为零时客户数计为零。只有一个有效日输入时保留该日来源客单价和商品转化率。

销售构成按店铺展示所选周及此前 4 周，末尾追加 MTD；报告周期仅选择周。历史月份 MTD 统计月初至所选周结束日。本月按昨天计算，表格和 CSV 不显示 MTD 截至日期。计算值附带已录入天数，CSV 同步携带说明。

### 指标名称与口径

| metrics 字段 | 对应抓取值 | 范围与精度 |
| --- | --- | --- |
| gmv | 成交金额，**正向、未扣退款**（沿用原字段名） | 非负数，最多 2 位小数 |
| refundAmount | 退款金额 | 非负数，最多 2 位小数；无退款传 0 |
| aov | 客单价 | 非负数，最多 6 位小数，保留抓取原值 |
| visitors | 店铺访客数 | 非负整数，当天去重访客数 |
| productConversionRate | 商品成交转化率 | 0–1 小数，最多 8 位小数；2.5% 传 `0.025` |
| parentOrders | 父单量（可选扩展） | 非负整数；保留原始父单量，不参与客户数推算，未抓取可不传 |

数值最多 15 位整数。`null`、空字符串、`NaN`、负退款、带 `%` 的字符串均不接受。零是有效数据，与缺失不同。

**看板正向 = 采集 `gmv`，逆向 = 采集 `refundAmount`（正数），GMV = 正向 − 逆向。** 退款可以超过成交金额，此时看板 GMV 为负。成交或退款任一缺失，GMV 显示 `—`；无退款必须明确传 `0`。

金额字段口径已更新：写入接口及 `/api/v1/metrics`、`/api/v1/records` 中的 `metrics.gmv` 是原始成交金额；`/api/dashboard` 的 `metrics[].gmv`、`daily[].gmv` 是扣退款后的计算结果。现有采集观测值按成交金额解释，不改写原始值或请求历史。若采集器此前提交的是已扣退款的净额，需按新口径重新采集，并用新的 `requestId`、真实的新 `collectedAt` 更新。

原始日客单价和商品成交转化率保留来源值；跨日、跨店合计采用上面的推算公式，不直接平均。推算客户数不会写入父单量。

### 五个任务分别抓取的流程

1. 成交金额任务提交 `{ "gmv": "12345.67" }`。
2. 退款任务提交 `{ "refundAmount": "345.67" }`。
3. 客单价任务提交 `{ "aov": "246.9134" }`。
4. 访客任务提交 `{ "visitors": 5000 }`。
5. 转化率任务提交 `{ "productConversionRate": "0.025" }`。

每个任务使用**相同的 shopId + grain=day + startDate=endDate**，各自生成 requestId 和实际 collectedAt。可以只交一项，也可以一次交齐五项。最终组合为一条完整店铺周期记录，各任务不会清空其他任务的指标。

同一请求允许一个店铺周期出现多次，但同一个指标不能重复；重复指标返回 422。首次创建同一店铺时，批次里需提供 shopAccount 和 shopName，可以位于该店铺的不同记录；同一请求内同店铺的账号和名称必须各自一致。已有店铺只传 shopId 和指标即可，省略账号、名称会保留现值。

### 成功响应

```json
{
  "requestId":"455ff1f4-8250-4c71-9599-833a09a0c7b1",
  "records":1,
  "metrics":1,
  "inserted":1,
  "updated":0,
  "stale":0,
  "unchanged":0,
  "replayed":false
}
```

统计单位：`records` 是请求记录数；其余 inserted / updated / stale / unchanged 是**指标数量**，它们之和等于 metrics。

- 同 requestId、相同规范化内容重试：返回首次结果，`replayed=true`，不重复入库。
- 同 requestId、不同内容：409 `IDEMPOTENCY_CONFLICT`，不写入。
- 更新采用**每项指标自身的 collectedAt**，不是请求到达顺序。
- 新采集时间更新旧值；较旧时间只计入 stale，不覆盖当前值。
- 相同时间、相同值计入 unchanged；相同时间、不同值返回 409 `TIMESTAMP_CONFLICT`，整批回滚。
- `source` 只标识来源，不作为隔离键；多个工具写同一店铺周期指标时，以较新 collectedAt 为准。
- 后台编辑与抓取工具使用同一条更新链路。删除数据不会删除请求历史；若需要重新录入已删除值，使用新的 requestId。

## 3. 查询某店铺某周期的录入完整度

### GET /api/v1/metrics?shopId=256730481&grain=day&startDate=2026-10-18&endDate=2026-10-18

```json
{
  "shopId":"256730481",
  "period":{"id":"2026-10-18:2026-10-18:day","grain":"day","startDate":"2026-10-18","endDate":"2026-10-18"},
  "metrics":{
    "gmv":{"value":"12345.67000000","collectedAt":"2026-10-19T01:30:00.000Z","source":"golden-eye-crawler","requestId":"455ff1f4-8250-4c71-9599-833a09a0c7b1"}
  },
  "complete":false,
  "missingMetrics":["refundAmount","aov","visitors","productConversionRate"]
}
```

读取 value 返回十进制字符串，避免浮点精度丢失；返回时间为 UTC。没有记录时返回 `200`、空 metrics 和全部五项 missingMetrics。parentOrders 不影响 complete。

### 查询计算后的周、月及 MTD

同一 `GET /api/v1/metrics` 支持 `grain=week|month|mtd`，只读计算结果，不创建记录。例如：

`GET /api/v1/metrics?shopId=256730481&grain=mtd&startDate=2026-10-01&endDate=2026-10-19`

响应中的 `calculated: true` 表示日数据计算结果；每项 `metrics` 仍返回 `value` 字符串，`source` 为 `daily-calculation`，没有虚构的 `requestId` 或采集时间。`metrics.gmv` 为累计成交金额，顶层 `netGmv` 为扣退款后的 GMV。`growth` 包含 GMV、访客、客单价、商品转化率环比，无比较数据或分母为零时为 `null`。

`coverage` 包含 `recordedDays`（已录入天数）、`expectedDays`（统计范围应有天数，最多到昨天）、`completeDays`（五项指标齐全的天数）、`metricDays`（各指标有值天数）。`complete` 仅在所有应有日期的五项输入齐全且结果可计算时为 true。没有日数据时 `metrics` 为空、`complete` 为 false。

日查询返回 `calculated: false`，保留实际采集时间和来源。写入、分页记录和删除接口只操作 day；不能编辑或删除计算出的周/月/MTD，应修改对应日数据。

## 4. 分页查询与删除指标记录

### GET /api/v1/records?page=1&pageSize=20&shopId=256730481&grain=day

page、pageSize 默认 1、20。page 最大 100000，pageSize 最大 500，界面支持每页 20 / 50 / 100 / 200 / 500 条。shopId、grain 可省略；grain 仅允许 day。默认按开始日期倒序、店铺 ID、粒度排序。可传 `sortBy`（shopId、shopName、grain、startDate、endDate、gmv、refundAmount、aov、visitors、productConversionRate、missingMetrics）及 `sortOrder`（asc / desc）。服务端对完整筛选结果排序后分页，数值按数字排序，缺失值始终置底；不支持的字段或方向返回 422。

可选 `startDate`、`endDate`（有效的 `YYYY-MM-DD`，2000–2099）筛选完整落在范围内的记录：记录开始日期不早于 startDate，结束日期不晚于 endDate。起止日期可单独传入，同时传入时开始不能晚于结束。

```json
{"total":1,"page":1,"pageSize":20,"records":[{"shopId":"256730481","shopName":"日系运动店","periodId":"2026-10-18:2026-10-18:day","grain":"day","startDate":"2026-10-18","endDate":"2026-10-18","metrics":{"gmv":"12345.67000000"},"collectedAt":"2026-10-19T01:30:00.000Z","missingMetrics":["refundAmount","aov","visitors","productConversionRate"]}]}
```

### DELETE /api/v1/records?shopId=256730481&grain=day&startDate=2026-10-18&endDate=2026-10-18

删除这一个店铺周期的全部采集指标。响应 `{"deletedMetrics":5}`。不存在时返回 `{"deletedMetrics":0}`。请求历史保留。

批量删除使用 `DELETE /api/v1/records`，设置 `Content-Type: application/json`：

```json
{"records":[{"shopId":"256730481","grain":"day","startDate":"2026-10-18","endDate":"2026-10-18"}]}
```

每次 1–500 条完整记录标识，重复标识自动去重。整批通过校验后在同一事务中删除，失败全部回滚；响应 `deletedMetrics` 为实际删除指标数量。后台的全选仅选当前页，切换页码或筛选条件会清空选择。

## 5. 看板查询

`GET /api/dashboard` 返回前端经营看板数据。录入成功后刷新看板即可读取；不会缓存旧值，也不会回退到本地示例数据。

未录入的指标显示 `—`；筛选合计累加全部筛选结果中已录入的值，不受分页影响。全部未录入时显示 `—`，已录入的零值计为零。合计环比按本期与参照期的已录入 GMV 合计计算，不累加或平均各店环比。日、周、月报告周期由已有日数据自动生成，经营数据默认优先月维度。

前台支持具体起止日期。占比自动计算为当前店铺 GMV / 同期全部店铺已录入 GMV 合计，未录入店铺不参与分母，店铺筛选、搜索和分页不改变分母。所有日期均保留完整指标列；月报与按月查看对比上一自然月，自定义范围对比紧邻的等长上一期。参照期数据不因日期筛选而丢失，并在经营表和导出中保留。

所有周期和自定义范围均按已有日记录计算，UV 为累计人次，客单价和转化率按推算人数计算。分母为零或缺失时不显示误导性的占比或环比。

## 6. 错误与重试

错误响应统一为：

```json
{"error":{"code":"VALIDATION_ERROR","message":"提交数据不符合接口约定。","details":[{"path":"records[0].metrics.productConversionRate","message":"比例范围 0–1；2.5% 应传 0.025"}]}}
```

| HTTP | code | 处理方式 |
| --- | --- | --- |
| 400 | INVALID_JSON | 修正 JSON |
| 404 | NOT_FOUND | 删除店铺时检查 ID |
| 409 | IDEMPOTENCY_CONFLICT | 新内容生成新的 requestId |
| 409 | TIMESTAMP_CONFLICT | 核对来源值，以实际新的采集时间提交修正 |
| 413 | PAYLOAD_TOO_LARGE | 拆分到 1 MiB 以内，每个新批次使用新 requestId |
| 415 | UNSUPPORTED_MEDIA_TYPE | 设置 application/json |
| 422 | VALIDATION_ERROR / UNKNOWN_SHOP | 修正字段、日期、数值；新店铺提供 shopAccount 和 shopName |
| 503 | DATABASE_UNAVAILABLE | 使用原 requestId 和原请求体退避重试 |

遇到网络超时也使用原 requestId 和原请求体重试，避免服务端已提交但客户端没收到响应造成重复。重试时不要改变 collectedAt。业务校验错误不应自动无限重试。

## 7. Python 工具调用

仓库提供 `scripts/submit-metrics.py`（只依赖 Python 标准库）。将请求写到 `batch.json`：

```shell
python scripts/submit-metrics.py --base-url http://localhost:3000 --file batch.json
```

脚本原样发送文件，不补造 ID 和时间；网络错误或 503 最多重试 3 次，始终保留相同请求体。可直接把 HTTP 调用部分集成到抓取程序。

```python
import json, uuid
from datetime import datetime, timezone, timedelta
from urllib.request import Request, urlopen

# 在抓取成功后生成一次并持久化；失败重试复用此 payload。
payload = {
    "requestId": str(uuid.uuid4()),
    "source": "my-crawler",
    "records": [{
        "shopId": "256730481",
        "shopAccount": "rakuten-main",
        "shopName": "日系运动店",
        "period": {"grain": "day", "startDate": "2026-10-18", "endDate": "2026-10-18"},
        "collectedAt": datetime.now(timezone(timedelta(hours=8))).isoformat(timespec="milliseconds"),
        "metrics": {"gmv": "12345.67", "refundAmount": "345.67", "aov": "246.9134", "visitors": 5000, "productConversionRate": "0.025"}
    }]
}
request = Request("http://localhost:3000/api/v1/ingestion",
                  data=json.dumps(payload).encode("utf-8"),
                  headers={"Content-Type": "application/json"}, method="POST")
with urlopen(request, timeout=30) as response:
    print(json.load(response))
```

## 8. 数据库结构

全部表位于独立 schema `shop_dashboard`。

| 表 | 主键／粒度 | 用途 |
| --- | --- | --- |
| shops | id（唯一主键） | account 账号、id 店铺ID、name 店铺名称；账号与名称不唯一 |
| collection_periods | id；唯一 grain + start_date + end_date | 仅 day 日周期，数据库约束起止日期必须相同 |
| metric_observations | shop_id + period_id + metric | 每项指标的当前值、实际采集时间、来源及请求 ID |
| ingestion_requests | request_id | 幂等去重、原始规范化请求、写入结果和接收时间；保留历史 |
| snapshots | id | 原报表快照结构，当前无预置数据 |
| periods | id | 原报表周期，含 MTD/YTD |
| period_metrics | snapshot_id + shop_id + period_id | 原周期指标、广告与动销 SPU 扩展字段 |
| daily_metrics | snapshot_id + shop_id + date | 原日指标结构 |
| listing_snapshots | snapshot_id + shop_id + period_id | 京麦／火种上架数量扩展 |

新抓取工具只需调用采集接口，由接口写入前四张表；无需创建整份快照，也不需直接操作 PostgreSQL。旧快照相关表保留结构但不再参与看板读取。广告、动销／上架 SPU 暂不在这版抓取接口范围内，未录入时展示 `—`。

### 数据关系

| 来源表 | 关系 | 指标表关联字段 |
| --- | --- | --- |
| shops | 一家店铺 → 多项周期指标 | metric_observations.shop_id |
| collection_periods | 一个日期 → 多家店铺指标 | metric_observations.period_id |
| ingestion_requests | 一次写入请求 → 多项最新指标 | metric_observations.request_id |

每项指标以 **shop_id + period_id + metric** 唯一定位，按自身 `collectedAt` 更新。请求历史单独保存，便于重试去重与追溯。

每日销售按已录入日记录汇总 GMV 和 UV，空数据店铺不使其他店铺的数据变空；合计统计当前范围已录入的日数据，不要求整月录满。每行环比仍读取上一自然日，合计环比对比参照范围内的日数据。UV 合计未跨店、跨日去重。转化率和客单价不直接平均，缺少计算依据时可切换“按店铺明细”查看各店原始值。销售构成、每日销售及后台表格均支持表头排序，先排序后分页，导出沿用前台排序。
