From c83669087bcb500f43fb60e7544ad3568e2f5dfe Mon Sep 17 00:00:00 2001 From: butubb <1422726308@qq.com> Date: Sun, 11 Oct 2026 09:21:01 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E7=9B=91=E6=8E=A7=E5=B1=82=E8=A1=A8?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E8=AF=B4=E6=98=8E=EF=BC=88=E5=B8=A6=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E5=A4=87=E6=B3=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 13 张表逐字段写清楚类型、可空、含义,外加四条必须先知道的约定: 毫秒时间戳、NULL 不是 0、平台 id 是 xhs/dy 而抖音落盘目录叫 douyin、作品从不删除。 和 db-schema.sql(原始 DDL)配套:那份给机器看,这份给人看。 --- docs/db-schema.md | 347 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 347 insertions(+) create mode 100644 docs/db-schema.md diff --git a/docs/db-schema.md b/docs/db-schema.md new file mode 100644 index 0000000..335de92 --- /dev/null +++ b/docs/db-schema.md @@ -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)存进来但**从不原样返回**给 +前端,只回「有没有设置」和长度。