# GovCrawler 数据库设计文档

版本：v2.8（MySQL 部署版）
最后更新：2026-05-14
部署环境：`mysql://GovCrawler@192.168.1.222:3306/GovCrawler`，`utf8mb4` / `utf8mb4_unicode_ci`
迁移版本：`0014_add_article_standard_meta`（通过 `uv run alembic upgrade head` 对齐）

---

## 1. 设计目标与总体结构

GovCrawler 的持久层负责保存四类数据：

- **配置与主数据**：站点、栏目、部门、全局栏目目录。
- **采集结果**：文章、附件、正文路径、原始 HTML 路径、标准化元数据。
- **运行状态**：持久化采集任务、断点页码、抓取日志、RAG 导出状态。
- **安全审计**：后台和 RAG ack 的状态变更审计。

当前共 **11 张业务表 + 1 张 Alembic 版本表**：

```text
crawl_site ───┬────────> site_department ──> local_department
              │                                     ↑
              ├────────> crawl_target ──────────────┤
              │              │
              │              ├──────> crawl_job
              │              └──────> crawl_log
              │
              ├────────> article ───> attachment
              │              │
              │              └──────> article_standard_meta
              │
              └────────> master_column_registry ──> crawl_target

admin_audit_log 独立记录状态变更审计
```

核心铁律：

1. **URL 全局去重**：`article.url_hash` 必须唯一。
2. **站点配置来源清晰**：`crawl_site.managed_by='yaml'` 由 YAML 同步托管；`'ui'` 由管理后台托管。
3. **部门绑定一致**：`site_department.dept_binding='mapped'` 时必须有 `local_dept_id`，其他绑定类型必须为空。
4. **文章历史优先保护**：`article.site_id` 对 `crawl_site.id` 是 `RESTRICT`，有文章的站点不能直接删除。
5. **采集任务可恢复**：`crawl_job` 是任务队列的持久化镜像，API 重启时恢复 queued/running 状态。
6. **RAG 只消费 ready 数据**：RAG 侧以 `article.status='ready'` 和导出状态字段作为同步边界。

---

## 2. 表结构规范

### 2.1 `crawl_site` — 站点登记

一个 row = 一个 CMS 网站或 YAML 站点。

| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | BIGINT PK AUTO_INCREMENT | | 内部 PK |
| `site_code` | VARCHAR(50) | UNIQUE NOT NULL | 业务编码，例 `gd_gkmlpt` / `fogang` |
| `site_name` | VARCHAR(200) | | 中文名 |
| `base_url` | VARCHAR(500) | | 根 URL |
| `site_role` | VARCHAR(50) | | `qingyuan_local` / `county_local` / `province_ref` / `nation_ref` |
| `cms_adapter` | VARCHAR(50) | XOR `yaml_path` | 适配器 ID，例 `gkmlpt` / `trs_wcm` |
| `adapter_params_json` | JSON | | 适配器参数 |
| `default_fetch_strategy` | VARCHAR(50) | | 默认抓取策略，例 `httpx` / `playwright` |
| `strategy_override_reason` | VARCHAR(200) | | 覆写原因 |
| `respect_robots` | BOOLEAN | default TRUE | 是否遵守 robots |
| `yaml_path` | VARCHAR(500) | XOR `cms_adapter` | 旧 YAML 配置路径 |
| `managed_by` | VARCHAR(10) | default `yaml` | `yaml` / `ui` |
| `enabled` | BOOLEAN | default TRUE | 启停开关 |
| `remark` | VARCHAR(500) | | 备注 |
| `crawl_window_start` | TIME | | 允许采集窗口开始 |
| `crawl_window_end` | TIME | | 允许采集窗口结束 |
| `daily_max_requests` | INT | | 单站每日请求上限 |
| `weekend_enabled` | BOOLEAN | default TRUE | 周末是否允许采集 |
| `backoff_min_sec` | INT | | 退避最小秒数 |
| `backoff_max_sec` | INT | | 退避最大秒数 |
| `schedule_cron` | VARCHAR(100) | | 站点级默认 cron |
| `created_at` | DATETIME | default NOW | |
| `updated_at` | DATETIME | default NOW | |

约束：

- `ck_crawl_site_adapter_xor_yaml`：`cms_adapter` / `yaml_path` 两者恰好一个非空。
- `ck_crawl_site_managed_by`：`managed_by IN ('yaml','ui')`。

---

### 2.2 `site_department` — 站点部门路径

一个 row = 某站点下发现或配置的一个部门路径，用于映射 OA 权威部门。

| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | BIGINT PK | | |
| `site_id` | BIGINT | FK `crawl_site.id` CASCADE, NOT NULL | 所属站点 |
| `dept_path` | VARCHAR(100) | NOT NULL | 站点侧路径，例 `/edu/` |
| `local_dept_id` | BIGINT | FK `local_department.dept_id` SET NULL | OA 部门 |
| `dept_binding` | VARCHAR(20) | default `pending` | `pending` / `mapped` / `city_level` / `cross_dept` / `external_ref` |
| `dept_display_name` | VARCHAR(200) | | 显示名 |
| `detect_status` | VARCHAR(20) | default `pending` | 自动探测状态 |
| `last_probed_at` | DATETIME | | 最近探测时间 |
| `last_classify_json` | JSON | | 分类推断结果 |
| `enabled` | BOOLEAN | default TRUE | |
| `created_at` | DATETIME | default NOW | |
| `updated_at` | DATETIME | default NOW | |

约束：

- `uq_site_department_path`：`(site_id, dept_path)` 唯一。
- `ck_site_department_binding`：绑定类型枚举。
- `ck_site_department_binding_local_dept`：`mapped` 必须有 OA 部门，非 `mapped` 必须为空。

---

### 2.3 `crawl_target` — 采集目标

一个 row = 一个具体栏目或入口的采集任务。

| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | BIGINT PK | | |
| `site_id` | BIGINT | FK `crawl_site.id` CASCADE, NOT NULL | 所属站点 |
| `site_department_id` | BIGINT | FK `site_department.id` CASCADE | 可选部门路径 |
| `target_code` | VARCHAR(100) | UNIQUE NOT NULL | 业务编码，例 `gd_wjk__qbwj` |
| `target_name` | VARCHAR(200) | | 中文名 |
| `entry_url` | VARCHAR(1000) | | 栏目入口 URL |
| `sample_article_url` | VARCHAR(1000) | | dry-run 样本 URL |
| `dept_id` | BIGINT | FK `local_department.dept_id` SET NULL | 直接绑定 OA 部门 |
| `parser_override_json` | JSON | | 选择器覆盖 |
| `channel_name` | VARCHAR(200) | | 栏目名 |
| `channel_path` | VARCHAR(1000) | | 栏目路径 |
| `content_category` | VARCHAR(100) | | 一级分类 |
| `content_subcategory` | VARCHAR(100) | | 二级分类 |
| `schedule_cron` | VARCHAR(100) | | target 级 cron |
| `expected_cadence_days` | INT | default 30 | 健康度窗口 |
| `interval_sec` | INT | | 单篇详情抓取基础间隔 |
| `interval_jitter_sec` | INT | | 正向随机抖动秒数 |
| `track_checkpoint` | BOOLEAN | default FALSE | 是否启用页级断点 |
| `enabled` | BOOLEAN | default TRUE | |
| `last_crawled_at` | DATETIME | | 最近列表抓取时间 |
| `last_article_time` | DATETIME | | 最近入库文章时间 |
| `created_at` | DATETIME | default NOW | |
| `updated_at` | DATETIME | default NOW | |

说明：

- 实际等待时间为 `interval_sec + random(0..interval_jitter_sec)`。
- `track_checkpoint=TRUE` 时，pipeline 会把已完成页写入 `crawl_job.last_completed_page`，重启后可从下一页继续。

---

### 2.4 `local_department` — OA 权威部门

清远市/县区政府组成部门、乡镇和虚拟参考节点主数据。PK 使用 OA 原始 `dept_id`。

| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `dept_id` | BIGINT PK | | OA 部门 ID |
| `dept_name` | VARCHAR(250) | | 正式名称 |
| `parent_dept_id` | BIGINT | | 父级 ID，非强制 FK |
| `dept_level` | INT | | 1 市级 / 2 县区 / 3 镇街 |
| `dept_code` | VARCHAR(250) | | OA 编码 |
| `short_name` | VARCHAR(50) | | 简称 |
| `full_name` | VARCHAR(500) | | 全称 |
| `state` | INT | | OA 状态 |
| `order_id` | INT | | 排序 |
| `updated_at` | DATETIME | | OA 更新时间 |
| `region` | VARCHAR(50) | INDEX | 清远市/县区归属 |

种子脚本：

- `scripts/import_local_departments.py`
- `scripts/backfill_dept_region.py`

---

### 2.5 `article` — 文章主表

一个 row = 一篇抓取文章。RAG 主要消费这张表及其附件、标准元数据。

核心字段：

| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | BIGINT PK | | |
| `site_id` | BIGINT | FK `crawl_site.id` RESTRICT, NOT NULL | 所属站点 |
| `target_id` | BIGINT | FK `crawl_target.id` SET NULL | 所属目标 |
| `dept_id` | BIGINT | FK `local_department.dept_id` SET NULL | 归属 OA 部门 |
| `native_post_id` | VARCHAR(64) | | CMS 原始 ID |
| `url` | TEXT | NOT NULL | 原文 URL |
| `url_hash` | CHAR(64) | UNIQUE NOT NULL | URL SHA-256 |
| `title` | TEXT | | 标题 |
| `publish_time` | DATETIME | | 发布日期时间，系统按 UTC 存储 |
| `source_raw` | VARCHAR(500) | | 源信息原始快照 |

政府信息公开标准字段：

| 字段 | 类型 | 说明 |
|---|---|---|
| `index_no` | VARCHAR(200) | 索引号 |
| `doc_no` | VARCHAR(200) | 文号 |
| `publisher` | VARCHAR(500) | 发文机关 / 发布机构 / 来源 |
| `publish_date` | DATE | 发布日期或成文日期的日期部分，依站点语义映射 |
| `effective_date` | DATE | 生效日期 |
| `is_effective` | BOOLEAN | 是否有效，NULL 表示未知 |
| `expiry_date` | DATE | 失效/废止日期 |
| `topic_words` | VARCHAR(500) | 主题词 |
| `open_category` | VARCHAR(200) | 源站完整分类 |
| `content_category` | VARCHAR(100) | 一级内容分类 |
| `content_subcategory` | VARCHAR(100) | 二级内容分类 |

内容、路径与状态：

| 字段 | 类型 | 说明 |
|---|---|---|
| `channel_name` | VARCHAR(200) | 栏目名 |
| `channel_path` | VARCHAR(1000) | 栏目路径 |
| `metadata_json` | JSON | 扩展元数据；优先保留源站 raw payload / public_meta |
| `content_text` | TEXT | 正文纯文本 |
| `raw_html_path` | TEXT | 原始 HTML 文件路径 |
| `text_path` | TEXT | 纯文本文件路径 |
| `has_attachment` | BOOLEAN | 是否有附件 |
| `status` | VARCHAR(20) | `raw` / `ready` / `failed` |
| `fetch_strategy` | VARCHAR(50) | 实际抓取策略 |
| `fetched_at` | DATETIME | 抓取时间 |
| `created_at` | DATETIME | 创建时间 |
| `updated_at` | DATETIME | 更新时间 |

RAG 导出状态：

| 字段 | 类型 | 说明 |
|---|---|---|
| `exported_to_rag_at` | DATETIME | RAG ack 后写入 |
| `rag_export_status` | VARCHAR(20) | `pending` / `exporting` / `done` / `failed` 等应用层状态 |
| `rag_export_started_at` | DATETIME | 导出开始时间 |
| `rag_export_finished_at` | DATETIME | 导出结束时间 |
| `rag_export_error` | TEXT | 导出错误 |
| `rag_export_task_ids` | JSON | 下游 RAG 任务 ID 列表 |

索引与约束：

- `uq_article_url_hash`：`url_hash` 唯一。
- `uq_article_site_native`：`(site_id, native_post_id)` 唯一。MySQL 允许多个 `NULL`，因此未提供 `native_post_id` 的文章不会互相冲突。
- `ix_article_site_pub`：`(site_id, publish_time)`。
- `ix_article_target_pub`：`(target_id, publish_time)`。
- `ix_article_exported`：`exported_to_rag_at`。
- `ix_article_rag_export_status`：`rag_export_status`。
- `ix_article_status`：`status`。
- `ix_article_is_effective`：`is_effective`。
- `ix_article_expiry_date`：`expiry_date`。
- `ck_article_status`：`status IN ('raw','ready','failed')`。

---

### 2.6 `article_standard_meta` — 标准文献扩展元数据

一个 row = 一篇标准类文章的结构化标准信息。不是所有文章都有该扩展。

| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | BIGINT PK | | |
| `article_id` | BIGINT | FK `article.id` CASCADE, UNIQUE, NOT NULL | 关联文章 |
| `standard_no` | VARCHAR(100) | INDEX | 标准号 |
| `chinese_title` | TEXT | | 中文标准名称 |
| `english_title` | TEXT | | 英文标准名称 |
| `standard_status` | VARCHAR(100) | | 标准状态 |
| `ccs_codes` | JSON | | CCS 分类 |
| `ics_codes` | JSON | | ICS 分类 |
| `publish_date` | DATE | INDEX | 发布日期 |
| `implementation_date` | DATE | INDEX | 实施日期 |
| `competent_department` | VARCHAR(500) | | 主管部门 |
| `technical_committee` | VARCHAR(500) | | 归口单位 / 技术委员会 |
| `issuing_body` | VARCHAR(500) | | 发布单位 |
| `standard_type` | VARCHAR(100) | | 国家标准 / 行业标准等 |
| `raw_meta_json` | JSON | | 源站原始标准元数据 |
| `created_at` | DATETIME | default NOW | |
| `updated_at` | DATETIME | default NOW | |

索引：

- `uq_article_standard_meta_article`：`article_id` 唯一。
- `ix_standard_meta_standard_no`
- `ix_standard_meta_publish_date`
- `ix_standard_meta_implementation_date`

---

### 2.7 `attachment` — 附件

| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | BIGINT PK | | |
| `article_id` | BIGINT | FK `article.id` CASCADE, NOT NULL | 所属文章 |
| `file_name` | TEXT | | 文件名 |
| `file_ext` | VARCHAR(20) | | 扩展名 |
| `size_bytes` | BIGINT | | 文件大小 |
| `file_path` | TEXT | | 落盘路径 |
| `file_hash` | CHAR(64) | INDEX | 文件 SHA-256 |
| `downloaded_at` | DATETIME | default NOW | 下载时间 |

索引：`ix_attachment_file_hash`。

---

### 2.8 `crawl_log` — 抓取日志

一条记录表示一次列表页、详情页或附件相关抓取尝试。

| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | BIGINT PK | | |
| `site_id` | BIGINT | FK `crawl_site.id` SET NULL | |
| `target_id` | BIGINT | FK `crawl_target.id` SET NULL | |
| `article_url` | TEXT | | 关联 URL |
| `strategy` | VARCHAR(50) | | `httpx` / `playwright` 等 |
| `http_status` | INT | | HTTP 状态 |
| `duration_ms` | INT | | 耗时 |
| `success` | BOOLEAN | NOT NULL | 是否成功 |
| `error_msg` | TEXT | | 错误摘要 |
| `occurred_at` | DATETIME | default NOW | 发生时间 |

索引：

- `ix_crawl_log_site_occurred`：`(site_id, occurred_at)`。
- `ix_crawl_log_target_occurred`：`(target_id, occurred_at)`。

---

### 2.9 `master_column_registry` — 全局栏目目录

CMS 适配器探测到的栏目目录，不代表全部订阅为 `crawl_target`。

| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | BIGINT PK | | |
| `adapter_id` | VARCHAR(50) | NOT NULL | 适配器 ID |
| `site_id` | BIGINT | FK `crawl_site.id` CASCADE, NOT NULL | 所属站点 |
| `column_id` | VARCHAR(64) | NOT NULL | CMS 栏目 ID |
| `column_name` | VARCHAR(500) | | 栏目名 |
| `column_path` | VARCHAR(1000) | | 栏目路径 |
| `admin_level` | VARCHAR(20) | | `city` / `county` / `township` |
| `topic_tags` | JSON | | 主题标签 |
| `post_count` | INT | | 近期文章数 |
| `last_seen_at` | DATETIME | | 最近探测 |
| `subscribed_target_id` | BIGINT | FK `crawl_target.id` SET NULL | 已订阅目标 |
| `active` | BOOLEAN | default TRUE | 是否仍有效 |
| `created_at` | DATETIME | default NOW | |
| `updated_at` | DATETIME | default NOW | |

约束：`uq_mcr_adapter_site_col`：`(adapter_id, site_id, column_id)` 唯一。

---

### 2.10 `crawl_job` — 持久化采集任务

`crawl_job` 是内存任务队列的 durable mirror。API 容器重启时，用它恢复 queued/running 任务，并保留超过内存历史窗口的任务记录。

| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `job_id` | VARCHAR(32) PK | | 任务 ID |
| `host` | VARCHAR(255) | INDEX, NOT NULL | 从站点 URL 提取，用于 per-host 串行化 |
| `site_code` | VARCHAR(64) | INDEX, NOT NULL | 站点编码快照 |
| `target_code` | VARCHAR(128) | INDEX, NOT NULL | 目标编码快照 |
| `source` | VARCHAR(20) | NOT NULL | `manual` / `schedule` / `retry` |
| `status` | VARCHAR(20) | INDEX, NOT NULL | `queued` / `running` / `done` / `failed` / `canceled` |
| `force` | BOOLEAN | default FALSE | 是否全量 / 强制 |
| `stop_requested` | BOOLEAN | default FALSE | 取消请求标记 |
| `attempt_count` | INT | default 0 | 尝试次数 |
| `last_completed_page` | INT | default 0 | 安全断点页，已完整处理页 |
| `current_page` | INT | default 0 | 当前处理页，仅展示进度 |
| `enqueued_at` | DATETIME | default NOW | 入队时间 |
| `started_at` | DATETIME | | 开始时间 |
| `finished_at` | DATETIME | | 结束时间 |
| `error_msg` | TEXT | | 失败原因 |
| `result_json` | JSON | | 结果摘要 |

索引：

- `ix_crawl_job_host`
- `ix_crawl_job_site_code`
- `ix_crawl_job_target_code`
- `ix_crawl_job_status`
- `ix_crawl_job_status_enqueued`：`(status, enqueued_at)`

说明：

- `last_completed_page` 是恢复边界，只有整页完成后才推进。
- `current_page` 是实时进度，不作为恢复边界。
- 取消任务时先写 `stop_requested=TRUE`，worker 在安全检查点退出。

---

### 2.11 `admin_audit_log` — 操作审计日志

后台状态变更和 RAG ack 的审计表。只存 payload 摘要，不存原始请求体。

| 字段 | 类型 | 约束 | 说明 |
|---|---|---|---|
| `id` | BIGINT PK | | |
| `created_at` | DATETIME | INDEX, default NOW | 操作时间 |
| `actor` | VARCHAR(64) | INDEX | 操作者；Basic Auth 用户或 RAG actor |
| `actor_ip` | VARCHAR(64) | | 来源 IP |
| `method` | VARCHAR(8) | NOT NULL | HTTP 方法 |
| `path` | VARCHAR(500) | NOT NULL | 请求路径 |
| `status_code` | INT | | 响应状态 |
| `duration_ms` | INT | | 耗时 |
| `payload_digest` | VARCHAR(32) | | `sha256(body)` 前缀，不存明文 |
| `action` | VARCHAR(64) | INDEX | 动作分类，例 `target.run` |
| `resource_type` | VARCHAR(32) | | 资源类型 |
| `resource_id` | VARCHAR(128) | | 资源 ID |

索引：

- `ix_audit_created_at`
- `ix_audit_actor`
- `ix_audit_action`
- `ix_audit_actor_created`：`(actor, created_at)`

---

## 3. 外键级联与删除语义

| 表.字段 | FK | ON DELETE | 语义 |
|---|---|---|---|
| `site_department.site_id` | `crawl_site.id` | CASCADE | 站点删，部门路径一起删 |
| `site_department.local_dept_id` | `local_department.dept_id` | SET NULL | OA 部门删，绑定置空 |
| `crawl_target.site_id` | `crawl_site.id` | CASCADE | 站点删，target 一起删 |
| `crawl_target.site_department_id` | `site_department.id` | CASCADE | 部门路径删，其下 target 一起删 |
| `crawl_target.dept_id` | `local_department.dept_id` | SET NULL | OA 部门删，target 绑定置空 |
| `article.site_id` | `crawl_site.id` | RESTRICT | 有文章的站点禁止删 |
| `article.target_id` | `crawl_target.id` | SET NULL | target 删，历史文章保留 |
| `article.dept_id` | `local_department.dept_id` | SET NULL | OA 部门删，文章保留 |
| `article_standard_meta.article_id` | `article.id` | CASCADE | 文章删，标准元数据一起删 |
| `attachment.article_id` | `article.id` | CASCADE | 文章删，附件一起删 |
| `crawl_log.site_id` | `crawl_site.id` | SET NULL | 日志独立留存 |
| `crawl_log.target_id` | `crawl_target.id` | SET NULL | 日志独立留存 |
| `master_column_registry.site_id` | `crawl_site.id` | CASCADE | 站点删，栏目目录一起删 |
| `master_column_registry.subscribed_target_id` | `crawl_target.id` | SET NULL | 订阅 target 删，目录项保留 |

`crawl_job` 和 `admin_audit_log` 不设外键，保存的是运行快照和审计快照，避免站点/target 删除后历史不可读。

---

## 4. 迁移版本管理（Alembic）

```text
alembic/versions/
├── 0001_initial_schema.py              # 原始 v1 表结构
├── 0002_schema_v2_rebuild.py           # v2 主表重建
├── 0003_add_managed_by.py              # crawl_site.managed_by
├── 0004_add_local_dept_region.py       # local_department.region
├── 0005_site_crawl_plan.py             # 站点级采集窗口 / backoff / 日上限
├── 0006_add_site_schedule_cron.py      # crawl_site.schedule_cron
├── 0007_add_crawl_job.py               # 持久化 crawl_job
├── 0008_add_admin_audit_log.py         # 操作审计 admin_audit_log
├── 0009_add_target_checkpoint.py       # crawl_target.track_checkpoint / crawl_job.last_completed_page
├── 0010_add_rag_export_state.py        # RAG 导出状态字段
├── 0011_add_article_validity_fields.py # 文章有效性 / 失效日期
├── 0012_add_target_interval_jitter.py  # target 级间隔随机抖动
├── 0013_add_crawl_job_current_page.py  # 任务当前页进度
└── 0014_add_article_standard_meta.py   # 标准文献扩展元数据
```

常用命令：

```bash
uv run alembic upgrade head
uv run alembic current
uv run alembic history --verbose
```

---

## 5. 数据导入与回填脚本

| 脚本 | 作用 |
|---|---|
| `scripts/import_local_departments.py` | 从 Excel 导入 OA 部门到 `local_department` |
| `scripts/backfill_dept_region.py` | 回填 `local_department.region` |
| `scripts/pg_to_mysql.py` | 旧 Postgres 到 MySQL 一次性迁移 |
| `scripts/backfill_gov_cn_zcwjk_metadata.py` | 从 gov.cn 政策库 raw payload 回填标准字段 |
| `scripts/backfill_gd_gkmlpt_metadata.py` | 从 gd.gov.cn gkmlpt raw payload / HTML 回填标准字段 |
| `scripts/backfill_article_detail_metadata.py` | 从已保存详情 HTML 抽取通用公开字段 |

回填原则：

- 优先使用 `article.metadata_json.raw`，其次使用 `raw_html_path`。
- 不重新抓源站，避免增加目标站压力。
- 不用“猜测值”填充索引号、文号等强语义字段；源站没有就保持空。

---

## 6. 典型查询

24 小时成功率：

```sql
SELECT COUNT(*) AS total,
       SUM(success) AS ok,
       ROUND(SUM(success) / COUNT(*) * 100, 2) AS ok_rate
FROM crawl_log
WHERE occurred_at >= NOW() - INTERVAL 1 DAY;
```

按站点统计文章字段覆盖：

```sql
SELECT cs.site_code,
       COUNT(a.id) AS total,
       SUM(a.index_no IS NOT NULL AND a.index_no <> '') AS index_no_count,
       SUM(a.publisher IS NOT NULL AND a.publisher <> '') AS publisher_count,
       SUM(a.doc_no IS NOT NULL AND a.doc_no <> '') AS doc_no_count,
       SUM(a.publish_date IS NOT NULL) AS publish_date_count
FROM article a
JOIN crawl_site cs ON cs.id = a.site_id
GROUP BY cs.site_code
ORDER BY total DESC;
```

未导出 RAG 的文章：

```sql
SELECT id, title, site_id, target_id, fetched_at
FROM article
WHERE status = 'ready'
  AND exported_to_rag_at IS NULL
ORDER BY fetched_at ASC
LIMIT 100;
```

RAG 导出失败队列：

```sql
SELECT id, title, rag_export_error, rag_export_finished_at
FROM article
WHERE rag_export_status = 'failed'
ORDER BY rag_export_finished_at DESC
LIMIT 100;
```

正在运行或等待的采集任务：

```sql
SELECT job_id, site_code, target_code, source, status,
       current_page, last_completed_page, enqueued_at, started_at
FROM crawl_job
WHERE status IN ('queued', 'running')
ORDER BY enqueued_at ASC;
```

某 target 最近任务历史：

```sql
SELECT job_id, status, force, current_page, last_completed_page,
       started_at, finished_at, error_msg
FROM crawl_job
WHERE target_code = 'gd_wjk__qbwj'
ORDER BY enqueued_at DESC
LIMIT 20;
```

后台操作审计：

```sql
SELECT created_at, actor, actor_ip, method, path, status_code,
       action, resource_type, resource_id
FROM admin_audit_log
ORDER BY created_at DESC
LIMIT 100;
```

标准文献检索：

```sql
SELECT a.id, a.title, m.standard_no, m.standard_status,
       m.publish_date, m.implementation_date
FROM article_standard_meta m
JOIN article a ON a.id = m.article_id
WHERE m.standard_no LIKE 'GB%'
ORDER BY m.publish_date DESC
LIMIT 100;
```

---

## 7. 索引与性能注意

- `article.url_hash` 是插入去重关键路径，必须保持唯一。
- 列表页增量判断主要依赖 `url_hash` 批量查重，不依赖标题匹配。
- 文章列表后台常用 `(site_id, publish_time)`、`(target_id, publish_time)`、`status`、`rag_export_status`。
- RAG 同步查询优先走 `status='ready'`、`exported_to_rag_at IS NULL`、`rag_export_status`。
- `crawl_log` 面向健康看板，按 `(site_id, occurred_at)` / `(target_id, occurred_at)` 查询。
- `crawl_job` 面向任务队列和运维恢复，按 `status`、`target_code`、`enqueued_at` 查询。
- `admin_audit_log` 是 append-only 表，长期运行建议按时间归档或清理。
- `metadata_json` / `raw_meta_json` 是恢复和审计用扩展字段，不应作为高频过滤条件。
- 大表预期：`article` 千万级、`crawl_log` 亿级。运行一年后可考虑按时间分区或归档日志。

---

## 8. MySQL 部署特殊说明

- 字符集：`utf8mb4` / `utf8mb4_unicode_ci`。
- 存储引擎：InnoDB。
- JSON：MySQL 5.7 支持 JSON 类型和 JSON 函数，但没有 PostgreSQL GIN 这类 JSON 索引。
- 布尔值：MySQL 实际按 TINYINT 存储，应用层使用 SQLAlchemy Boolean。
- 时间：应用层统一按 UTC 存 DATETIME；展示层按需要转北京时间。
- `uq_article_site_native`：MySQL UNIQUE 允许多个 `NULL`，因此 `native_post_id` 为空的行不会冲突；非空时同站点唯一。
- Alembic 是结构真相，生产升级必须先跑迁移，再启动新镜像。

---

## 9. 变更记录

| 版本 | 日期 | 变更 |
|---|---|---|
| v1.0 | 2026-03 | 初始 schema |
| v2.0 | 2026-04-22 | v2 重建：引入 `local_department` / `site_department` / `master_column_registry`，确立 `url_hash` 去重 |
| v2.1 | 2026-04-23 | 新增 `crawl_site.managed_by` |
| v2.2 | 2026-04-24 | 新增 `local_department.region`，部署到 MySQL |
| v2.3 | 2026-04-25 | 新增站点级采集窗口、backoff、每日上限、站点级 cron |
| v2.4 | 2026-04-26 | 新增 `crawl_job` 持久化任务表 |
| v2.5 | 2026-04-26 | 新增 `admin_audit_log` 操作审计 |
| v2.6 | 2026-04-27 | 新增 target 页级 checkpoint 和 job 当前页进度 |
| v2.7 | 2026-04-28 | 新增 RAG 导出状态、文章有效性字段、target 间隔 jitter |
| v2.8 | 2026-05-14 | 新增 `article_standard_meta`；同步当前 11 张业务表、迁移链和查询说明 |
