docs: 监控层表结构说明(带字段备注)
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

13 张表逐字段写清楚类型、可空、含义,外加四条必须先知道的约定:
毫秒时间戳、NULL 不是 0、平台 id 是 xhs/dy 而抖音落盘目录叫 douyin、作品从不删除。

和 db-schema.sql(原始 DDL)配套:那份给机器看,这份给人看。
This commit is contained in:
2026-10-11 09:21:01 +08:00
parent ab6bbac596
commit c83669087b
+347
View File
@@ -0,0 +1,347 @@
# 监控层数据库表结构
`api/monitor/**` 这一层用到的全部表。库名 `mediacrawler_prod`(MySQL 5.7,utf8mb4)。
配套的原始 DDL 在 [`db-schema.sql`](./db-schema.sql) —— 那份是 `SHOW CREATE TABLE` 直接导的,
含引擎、字符集和索引定义;本文是**带字段说明**的可读版。
> **这两份都不是迁移脚本。** 建表和补列是 `api/monitor/db.py::init_db()` 在每次启动时按 ORM
> 元数据自动做的:`create_all` 建缺失的表,`_ensure_columns` 对已存在的表逐列 `ALTER TABLE
> ADD COLUMN`(新增 NOT NULL 列会带上默认值给老行兜底)。所以加一个字段只需要改
> `models.py`,不用手写 SQL。
---
## 读之前要知道的四条约定
1. **时间戳一律是毫秒**(`bigint`),由 `tools/time_util.get_current_timestamp()` 产生。
平台给秒的(抖音是秒)在 `adapters.PlatformAdapter.time_scale` 里换算 —— 差 1000 倍,
不换算的话 2026 年的作品会显示成 1970 年。
2. **NULL 和 0 是两回事。** 指标、粉丝数这些「读不出来」的值一律存 **NULL,不存 0**:
0 在趋势图上是一条砸到底的线,和「不知道」完全不同。界面上 NULL 显示成「—」。
3. **平台 id 是 `xhs` / `dy`**(`api/monitor/adapters.py`)。注意 `dy` 是监控层的 id,
爬虫落盘目录叫 `douyin` —— 两者不一致,靠 `adapters.artifact_dir()` 换算。
4. **作品删除是从来不做的。** 掉出采集窗口的作品仍然留在库里(趋势图、报表、导出要用),
只是界面不再显示。
---
## 表之间的关系
```
monitor_task ─┬─< monitor_target 一个任务配若干个目标(博主 / 作品)
├─< monitor_run 每次运行一条
├─< monitor_note ──< monitor_note_metric
└─< monitor_creator_stat
monitor_note ──┬── monitor_note_alias (按 platform + note_id 关联,不是外键)
└── monitor_note_tag ──< monitor_tag
monitor_creator_alias (按 platform + creator_hash 关联)
monitor_event 事件流,多数挂在 task / run 上
monitor_setting 键值设置,独立
```
有真正外键(`ON DELETE CASCADE`)的只有 `task_id`:`monitor_target` / `monitor_run` /
`monitor_note` / `monitor_creator_stat` 都指向 `monitor_task`,删任务会一并带走。
其余关联**刻意不用外键**:它们跨的是「平台 + 业务 id」而不是主键,而且备注/标签是人为
维护的,不该跟着采集数据被级联掉。
---
## monitor_task — 监控任务
| 字段 | 类型 | 空 | 说明 |
|---|---|:--:|---|
| `id` | int | 否 | 主键 |
| `name` | varchar(200) | 否 | 任务名,人起的 |
| `platform` | varchar(32) | 否 | `xhs` / `dy` |
| `mode` | varchar(16) | 否 | `creator` 博主模式 / `note` 作品模式 |
| `enabled` | tinyint(1) | 否 | 是否参与定时调度 |
| `interval_minutes` | int | 否 | 间隔模式的周期(分钟) |
| `max_notes_count` | int | 否 | 每个博主每轮采多少条。**作品栏也按它开窗**(只画最新 N 条),但库里不删 |
| `enable_comments` | tinyint(1) | 否 | 是否采评论 |
| `max_comments_count` | int | 否 | 每条作品最多采多少条评论 |
| `run_timeout_seconds` | int | 否 | 一轮的墙钟上限,超了判 `timeout` |
| `notify_enabled` | tinyint(1) | 否 | 推送「有新作品」。默认**关**——可能每轮都有,会刷屏 |
| `notify_failures` | tinyint(1) | 否 | 推送「出异常了」(登录失效 / 运行失败 / 没抓到数据)。默认**开** |
| `schedule_mode` | varchar(16) | 否 | `interval` / `daily` / `weekly` |
| `schedule_hours` | varchar(96) | 否 | `daily`/`weekly` 用:几点跑,逗号分隔,如 `9,18` |
| `schedule_days` | varchar(32) | 否 | `weekly` 用:周几,**0 = 周一**(对齐 Python 的 `date.weekday()`) |
| `schedule_minute` | int | 否 | 整点后的第几分钟,三个模式共用 |
| `next_run_at` | bigint | 是 | 下次该跑的时刻。调度器就按这个字段挑任务 |
| `last_run_at` | bigint | 是 | 上次运行的**开始**时刻(不是结束) |
| `last_status` | varchar(32) | 否 | 上次运行状态,取值见 `monitor_run.status` |
| `last_error` | text | 是 | 上次失败的原因,给人看的一句话 |
| `last_notified_at` | bigint | 是 | 上次推送的时刻,用来限流 |
| `created_at` / `updated_at` | bigint | 否 | |
`schedule_hours` / `schedule_days` 存的是**逗号分隔的字符串**而不是关联表 —— 内容是几个
小整数,单独建表只会让每次读取多一跳。
---
## monitor_target — 任务的目标
一个任务可以配多个博主(或作品)。
| 字段 | 类型 | 空 | 说明 |
|---|---|:--:|---|
| `id` | int | 否 | 主键 |
| `task_id` | int | 否 | → `monitor_task.id`,**级联删除** |
| `kind` | varchar(16) | 否 | 等于任务的 `mode`:`creator` / `note` |
| `external_id` | varchar(128) | 否 | 规范化后的 id。博主是 sec_uid(抖音)/ user_id(小红书),作品是 note_id |
| `xsec_token` | varchar(512) | 否 | **只有小红书有**:分享链接里那个会过期的令牌。抖音恒为空串 |
| `xsec_source` | varchar(64) | 否 | 同上,配套的 source 参数 |
| `raw_value` | text | 否 | 用户当初粘进来的原文(URL 或裸 id),出问题时用来回溯 |
| `label` | varchar(200) | 否 | 展示用的名字 |
| `enabled` | tinyint(1) | 否 | 关掉的目标这一轮不采 |
| `created_at` | bigint | 否 | |
**唯一键** `(task_id, kind, external_id)` —— 同一个目标粘两次不会变成两行。
---
## monitor_run — 每一次运行
| 字段 | 类型 | 空 | 说明 |
|---|---|:--:|---|
| `id` | int | 否 | 主键 |
| `task_id` | int | 否 | → `monitor_task.id`,级联删除 |
| `trigger` | varchar(16) | 否 | `scheduled` 定时 / `manual` 手动 |
| `status` | varchar(16) | 否 | `pending` 排队中、`running` 运行中、`success`、`partial` 部分成功、`failed`、`timeout`、`interrupted` 被中断 |
| `phase` | varchar(16) | 否 | 建这条 run 时任务的 `mode`。任务中途改了模式,也还能知道当时跑的是什么 |
| `save_data_path` | text | 否 | 这一轮产物的落盘目录 |
| `queued_at` | bigint | 否 | 入队时刻 |
| `not_before` | bigint | 否 | 在这之前不许开跑(错峰 / 防抖) |
| `started_at` | bigint | 是 | 真正开跑的时刻 |
| `finished_at` | bigint | 是 | 结束时刻 |
| `exit_code` | bigint | 是 | 退出码。抖音走进程内 HTTP,值是 0 / 1;小红书走子进程。Windows 的进程崩溃会是大整数(NTSTATUS),`ingest.describe_exit_code()` 会翻译 |
| `notes_fetched` | int | 否 | 这一轮**读到**多少条作品 |
| `comments_fetched` | int | 否 | 读到多少条评论 |
| `new_notes` | int | 否 | 其中多少条是**新的** |
| `new_comments` | int | 否 | 同上 |
| `is_baseline` | tinyint(1) | 否 | 是否该任务的**第一轮成功运行**。基线轮刻意不发增量事件 —— 没有「上一轮」可比,全报成新增只是噪音 |
| `max_comments_count` | int | 否 | 跑的时候用的值快照。任务后来改了,历史记录仍然能解释得通 |
| `error_message` | text | 是 | 失败原因 |
---
## monitor_note — 作品(笔记 / 视频)
| 字段 | 类型 | 空 | 说明 |
|---|---|:--:|---|
| `id` | int | 否 | 主键 |
| `task_id` | int | 否 | → `monitor_task.id`,级联删除 |
| `note_id` | varchar(128) | 否 | 平台上的作品 id(抖音叫 `aweme_id`) |
| `title` | text | 否 | 标题 / 文案 |
| `note_url` | text | 否 | 作品页地址 |
| `cover` | text | 否 | 封面。**优先是本地缓存地址** `/api/monitor/covers/{note_id}`——远程图床地址带签名、会过期(实测隔天 403),本地那份不会 |
| `creator_hash` | varchar(64) | 否 | 创作者 id 的**单向哈希**。爬虫刻意不落原始 user_id,所以这是唯一稳定的创作者标识,作品栏按它分组 |
| `creator_name` | varchar(200) | 否 | 创作者昵称(本仓库关掉了脱敏,是原文) |
| `source_kind` | varchar(16) | 否 | 平台自己的作品类型:小红书取 `type`,抖音取 `aweme_type` |
| `published_at` | bigint | 是 | **作者发布**的时间。和 `first_seen_at` 完全不是一回事——把早就发过的作品加进监控时,两者能差好几个月 |
| `first_seen_run_id` | int | 是 | 第一次看到它是在哪一轮 |
| `first_seen_at` | bigint | 否 | **我们第一次看到它**的时刻 |
| `last_seen_run_id` | int | 是 | 最后一次采到它的轮次 |
| `last_seen_at` | bigint | 否 | 最后一次采到它的时刻 |
**唯一键** `(task_id, note_id)` —— 同一件作品被两个任务同时监控就是两行,各算各的增量。
> 掉出采集窗口的作品**不会被删**。界面按 `max_notes_count` 开窗只画最新 N 条,而库里保留
> 全部:趋势图、报表、导出读的都是这份全量。
---
## monitor_note_metric — 作品指标快照(趋势的来源)
一轮一条,作品的四项互动指标。
| 字段 | 类型 | 空 | 说明 |
|---|---|:--:|---|
| `id` | int | 否 | 主键 |
| `task_id` | int | 否 | 属于哪个任务 |
| `note_id` | varchar(128) | 否 | 哪条作品 |
| `run_id` | int | 否 | 哪一轮采的 |
| `captured_at` | bigint | 否 | 采集时刻 |
| `liked_count` | int | **是** | 点赞。**解析不出来就是 NULL,不是 0** |
| `comment_count` | int | **是** | 评论 |
| `collected_count` | int | **是** | 收藏 |
| `share_count` | int | **是** | 分享 |
| `raw_liked_count` | varchar(64) | 否 | 平台返回的**原文**(可能是 `1.2万`) |
| `raw_comment_count` | varchar(64) | 否 | 同上 |
| `raw_collected_count` | varchar(64) | 否 | 同上 |
| `raw_share_count` | varchar(64) | 否 | 同上 |
**唯一键** `(task_id, note_id, run_id)`。
留 `raw_*` 四列是为了**事后可审计**:解析规则出问题时,原文还在,能重新算一遍。
---
## monitor_comment — 评论
| 字段 | 类型 | 空 | 说明 |
|---|---|:--:|---|
| `id` | int | 否 | 主键 |
| `task_id` | int | 否 | 属于哪个任务 |
| `note_id` | varchar(128) | 否 | 评论挂在哪条作品下 |
| `comment_id` | varchar(128) | 否 | 平台上的评论 id |
| `content` | text | 否 | 评论正文 |
| `nickname` | varchar(200) | 否 | 评论人昵称 |
| `creator_hash` | varchar(64) | 否 | 评论人 id 的哈希 |
| `create_time` | bigint | 是 | 评论发布时间 |
| `like_count` | int | 是 | 这条评论的点赞 |
| `sub_comment_count` | int | 否 | 子评论数。平台没给就是不填,**不猜** |
| `parent_comment_id` | varchar(128) | 否 | 父评论 id。**顶层评论两边写法不同**:小红书是空串,抖音是 `"0"`——`adapters.parent_comment_id()` 会把 `"0"` 归一成空串 |
| `first_seen_run_id` | int | 是 | 第一次见到是在哪一轮 |
| `first_seen_at` | bigint | 否 | 第一次见到的时刻 |
**唯一键** `(task_id, note_id, comment_id)`。
> 评论接口**没有时间排序**,只能看到平台默认顺序下的前 N 条。所以第一次见到的评论分成
> 「上一轮之后发的」和「第一次见到的」两类事件,而不是一口咬定都是新发的。
---
## monitor_creator_stat — 博主账号级快照
一门一个人:粉丝数 / 总获赞 / 作品数。这是**作品列表给不了**的东西 —— 作品说的是「这条涨了
多少赞」,这里说的是「这个人整个账号在涨还是在掉」。
| 字段 | 类型 | 空 | 说明 |
|---|---|:--:|---|
| `id` | int | 否 | 主键 |
| `task_id` | int | 否 | → `monitor_task.id`,级联删除 |
| `run_id` | int | 否 | 哪一轮采的 |
| `creator_hash` | varchar(64) | 否 | 博主哈希(和 `monitor_note.creator_hash` 同一个口径) |
| `nickname` | varchar(128) | 否 | 昵称 |
| `creator_id` | varchar(128) | 否 | **平台上的主页 id**(抖音是 sec_uid)。用来拼「跳到他主页」的链接——哈希是单向的,拼不出地址 |
| `fans` | bigint | **是** | 粉丝数 |
| `total_favorited` | bigint | **是** | 总获赞 |
| `works_count` | bigint | **是** | 主页作品总数(和我们监控到的条数不是一回事) |
| `following` | bigint | **是** | 关注数 |
| `captured_at` | bigint | 否 | 采集时刻 |
**唯一键** `(task_id, creator_hash, run_id)`。
> **目前只有抖音会写这张表。** 小红书走的是爬虫子进程,而它的 `save_creator()` 在教学版里
> 是空函数,从来没落过创作者资料。所以小红书的博主没有粉丝数,也没有主页链接。
---
## monitor_creator_alias — 博主备注
| 字段 | 类型 | 空 | 说明 |
|---|---|:--:|---|
| `id` | int | 否 | 主键 |
| `platform` | varchar(16) | 否 | `xhs` / `dy` |
| `creator_hash` | varchar(64) | 否 | 哪个博主 |
| `alias` | varchar(128) | 否 | 人自己起的名字(「竞品A」)。空串表示没起过 |
| `updated_at` | bigint | 否 | |
**唯一键** `(platform, creator_hash)` —— 同一个博主出现在多个任务里,备注只填一次。
作品栏按 `creator_hash` 分组,可那是个哈希、昵称又常常认不出是谁;备注是唯一能把账号对上
人的东西。
---
## monitor_note_alias — 作品备注
| 字段 | 类型 | 空 | 说明 |
|---|---|:--:|---|
| `id` | int | 否 | 主键 |
| `platform` | varchar(16) | 否 | `xhs` / `dy` |
| `note_id` | varchar(128) | 否 | 哪条作品 |
| `alias` | varchar(128) | 否 | 一句话的备注 |
| `updated_at` | bigint | 否 | |
**唯一键** `(platform, note_id)`。
和博主备注是一对,但回答的不是同一个问题:博主备注回答「这个账号是谁」,作品备注回答
「这条我要盯着」。一个博主底下常常只有一两件值得盯的,所以不能合并。
---
## monitor_tag — 标签词表
在「设置 → 作品标签」里维护。**全局一套,不按平台分**——「重点」是给人自己用的心智,不该在
小红书和抖音各定义一遍。
| 字段 | 类型 | 空 | 说明 |
|---|---|:--:|---|
| `id` | int | 否 | 主键 |
| `name` | varchar(32) | 否 | 标签名 |
| `color` | varchar(16) | 否 | **调色板里的名字**:`cyan`/`pink`/`green`/`orange`/`muted`。不是色值——Tailwind 的类名是构建时静态提取的,拼出来的动态类名它看不见,线上会静默变无色 |
| `sort_order` | int | 否 | 展示顺序。新建时取 `max+1` 而不是 `count`:删过标签之后 count 会撞上已有的值 |
| `created_at` | bigint | 否 | |
**唯一键** `(name)` —— 两个「重点」在筛选列表里是灾难,选哪个都像对的。
---
## monitor_note_tag — 作品 ↔ 标签
| 字段 | 类型 | 空 | 说明 |
|---|---|:--:|---|
| `id` | int | 否 | 主键 |
| `platform` | varchar(16) | 否 | `xhs` / `dy` |
| `note_id` | varchar(128) | 否 | 哪条作品 |
| `tag_id` | int | 否 | → 逻辑上指向 `monitor_tag.id`,**但没有外键**(见下) |
| `created_at` | bigint | 否 | |
**唯一键** `(platform, note_id, tag_id)` —— **多对多**:一条作品可以既是「重点」又是「竞品」。
> 删标签时**显式删关联**,不依赖外键级联:测试跑的是 SQLite,而它默认不启用外键约束。
> 靠级联的话会出现「测试全绿、线上才对」或者反过来的情况。
标签和备注并存、不是一回事:备注是一句话的**自由文字**,标签是**封闭词表里的分类**。分类
必须封闭,否则「重点/重要/优先」各写各的,筛选就没法用了。
---
## monitor_event — 变化事件
界面上的「变化」列表和企业微信推送读的都是它。
| 字段 | 类型 | 空 | 说明 |
|---|---|:--:|---|
| `id` | int | 否 | 主键 |
| `task_id` | int | 否 | 属于哪个任务 |
| `run_id` | int | 是 | 由哪一轮产生 |
| `type` | varchar(32) | 否 | 见下表 |
| `severity` | varchar(16) | 否 | `info` / `warning` / `error` |
| `target_kind` | varchar(16) | 否 | 目前只有两种值:作品级的事件写 `note`,任务级的事件(运行失败 / 疑似登录失效 / 没抓到数据)留**空串** |
| `target_id` | varchar(128) | 否 | 对象 id —— 作品级的是作品 id(note_id / aweme_id),任务级的是空串 |
| `title` | text | 否 | 一句话。推送和列表里显示的就是它 |
| `payload_json` | text | 否 | 结构化细节(增量数值、退出码、失败原因…) |
| `created_at` | bigint | 否 | |
| `is_read` | tinyint(1) | 否 | 未读数就是按它统计的 |
`type` 的取值:
| 值 | 含义 |
|---|---|
| `new_note` | 发现新作品 |
| `new_comment_posted` | 上一轮之后**发布**的评论 |
| `new_comment_seen` | 第一次见到的评论(未必是刚发的,见评论那节的说明) |
| `metric_delta` | 指标有明显变化 |
| `run_failed` | 运行失败 |
| `suspected_auth_failure` | 疑似登录失效(一条都没抓到,且没有别的解释) |
| `no_data_found` | 没抓到数据,但原因不是登录(比如产物写在别的目录) |
---
## monitor_setting — 键值设置
| 字段 | 类型 | 空 | 说明 |
|---|---|:--:|---|
| `key` | varchar(64) | 否 | 主键。两种形状:`platform.<平台>.<名字>` / `system.<名字>` |
| `value` | text | 否 | **一律存字符串**,读的时候按注册表里的类型转 |
| `updated_at` | bigint | 否 | |
哪些 key 合法、各是什么类型/默认值,由 `api/monitor/app_settings.py` 的注册表决定 —— 设置页
的表单就是照它自动生成的。密钥类的设置(webhook 地址、cookie)存进来但**从不原样返回**给
前端,只回「有没有设置」和长度。