---
phase: 01-poc
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - pyproject.toml
  - .env.example
  - .gitignore
  - docker-compose.yml
  - alembic.ini
  - alembic/env.py
  - alembic/script.py.mako
  - alembic/versions/0001_initial_schema.py
  - govcrawler/__init__.py
  - govcrawler/settings.py
  - govcrawler/db.py
  - govcrawler/models.py
  - README.md
autonomous: true
requirements:
  - STORE-04
  - STORE-05
tags:
  - scaffolding
  - postgres
  - alembic
  - sqlalchemy

must_haves:
  truths:
    - "开发者执行 `uv sync` 安装所有运行与开发依赖无报错"
    - "开发者执行 `docker compose up -d db` 能启动本地 PostgreSQL 16 容器"
    - "开发者执行 `alembic upgrade head` 能在 PG 中创建 sites / columns / articles / attachments / crawl_logs 五张表"
    - "开发者执行 `alembic downgrade base` 能干净地删除全部五张表"
    - "articles 表有 url_hash UNIQUE 约束、content_simhash CHAR(16)、status 默认值 'raw'、exported_to_rag_at 字段"
    - "attachments 表有 file_hash 字段（后续 Phase 2 去重用）"
    - "SQLAlchemy ORM 模型与 Alembic migration 字段一一对应（同一来源）"
  artifacts:
    - path: "pyproject.toml"
      provides: "依赖清单 + 包元数据（uv 可识别）"
      contains: "patchright"
    - path: "docker-compose.yml"
      provides: "本地 PG16 容器定义"
      contains: "postgres:16-alpine"
    - path: "alembic/versions/0001_initial_schema.py"
      provides: "五张表的初始 DDL"
      contains: "create_table"
    - path: "govcrawler/models.py"
      provides: "SQLAlchemy 2.0 ORM models: Site Column Article Attachment CrawlLog"
      contains: "class Article"
    - path: "govcrawler/settings.py"
      provides: "pydantic-settings 配置读取（DB_URL / DATA_DIR / USER_AGENT）"
      contains: "class Settings"
    - path: "govcrawler/db.py"
      provides: "SQLAlchemy engine + Session factory (同步 psycopg3)"
      contains: "create_engine"
  key_links:
    - from: "alembic/env.py"
      to: "govcrawler/models.py"
      via: "target_metadata = Base.metadata"
      pattern: "target_metadata.*metadata"
    - from: "alembic/env.py"
      to: "govcrawler/settings.py"
      via: "sqlalchemy.url 从 Settings().db_url 读取"
      pattern: "Settings|DB_URL"
    - from: "docker-compose.yml"
      to: ".env.example"
      via: "同一个 POSTGRES_USER/PASSWORD/DB 名称"
      pattern: "POSTGRES_"
---

<objective>
搭建 govcrawler 项目骨架：Python 包结构、uv 依赖清单、本地 PG Docker 栈、Alembic 迁移系统、SQLAlchemy ORM 模型。
本阶段产出的是"可跑 alembic upgrade head"的空骨架，没有任何抓取/解析逻辑。

Purpose: Phase 1 的所有后续工作（Fetcher / Parser / Storage / CLI）都要写入 PG 与文件系统，必须先有可重建的 schema 和可导入的 models。STORE-04/STORE-05 的"建表"部分由本 plan 交付，"写入"部分由 Plan 02 交付。
Output: 可克隆即用的项目骨架 + 五张 PG 表的迁移脚本 + 一组 SQLAlchemy 2.0 ORM 模型。
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-poc/01-CONTEXT.md
@.planning/phases/01-poc/01-RESEARCH.md

<interfaces>
<!-- 本 plan 是全新项目骨架，没有上游代码要继承。以下是本 plan 将要创建的关键契约，供 Plan 02 消费。 -->

预期 SQLAlchemy 2.0 风格 models（Plan 02 会 import）：

```python
# govcrawler/models.py
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

class Base(DeclarativeBase): ...

class Site(Base):
    __tablename__ = "sites"
    # id, site_id UNIQUE, name, base_url, config_json, enabled, created_at

class Column(Base):
    __tablename__ = "columns"
    # id, site_id, column_id, name, category, list_url, config_json,
    # last_crawled_at, last_article_time, enabled

class Article(Base):
    __tablename__ = "articles"
    # id, site_id, column_id, category,
    # url, url_hash (CHAR(64) UNIQUE), content_simhash (CHAR(16)),
    # title, publish_time, source,
    # content_text, raw_html_path, text_path,
    # has_attachment, status ('raw'|'ready'|'failed', default 'raw'),
    # fetch_strategy, fetched_at, exported_to_rag_at

class Attachment(Base):
    __tablename__ = "attachments"
    # id, article_id (FK articles.id), file_name, file_ext,
    # size_bytes, file_path, file_hash (CHAR(64)), downloaded_at

class CrawlLog(Base):
    __tablename__ = "crawl_logs"
    # id, site_id, column_id, article_url, strategy,
    # http_status, duration_ms, success, error_msg, occurred_at
```

```python
# govcrawler/settings.py
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")
    db_url: str           # e.g. postgresql+psycopg://govcrawler:govcrawler@localhost:5432/govcrawler
    data_dir: str = "./data/govcrawler"
    user_agent: str = "GovCrawlerBot/1.0 (contact: xxx@example.com)"
```

```python
# govcrawler/db.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session

def get_engine(): ...
def get_sessionmaker() -> sessionmaker[Session]: ...
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: 项目骨架 + 依赖清单 + Docker PG</name>
  <files>
    pyproject.toml,
    .env.example,
    .gitignore,
    docker-compose.yml,
    README.md,
    govcrawler/__init__.py,
    govcrawler/settings.py,
    govcrawler/db.py
  </files>
  <read_first>
    - /Users/alphaPlanet/vibecoding/website/.planning/phases/01-poc/01-RESEARCH.md  (§Standard Stack, §Installation, §Recommended Project Structure)
    - /Users/alphaPlanet/vibecoding/website/.planning/phases/01-poc/01-CONTEXT.md  (§Implementation Decisions, §Claude's Discretion)
    - /Users/alphaPlanet/vibecoding/website/.planning/PROJECT.md  (§许可证白名单)
  </read_first>
  <action>
  创建以下文件。全部使用 Write 工具（绝不 heredoc）：

  1. **pyproject.toml**（uv 风格，PEP 621）
     - `[project]` name="govcrawler", version="0.1.0", requires-python=">=3.11"
     - `dependencies` 列表（pinned 如 RESEARCH.md §Installation）：
       - `patchright>=1.58,<2`
       - `parsel>=1.11,<2`
       - `GeneralNewsExtractor==0.1.3`
       - `trafilatura>=2.0,<3`  (二级兜底 — per user's additional_locked_decisions)
       - `SQLAlchemy>=2.0,<2.1`
       - `alembic>=1.18,<2`
       - `psycopg[binary]>=3.2,<4`  (LGPL-3 动态链接商用接受 — per user decision)
       - `simhash>=2.1,<3`
       - `httpx>=0.28,<1`
       - `pydantic-settings>=2.14,<3`
       - `python-slugify>=8,<9`
       - `tldextract>=5,<6`
       - `lxml>=5,<6`
     - `[project.optional-dependencies] dev = ["pytest>=8", "pytest-asyncio>=0.24", "pip-licenses"]`
     - `[project.scripts] govcrawler = "govcrawler.cli:main"`  (Plan 02 会创建 cli.py)
     - `[build-system] requires = ["hatchling"]`, `build-backend = "hatchling.build"`
     - `[tool.hatch.build.targets.wheel] packages = ["govcrawler"]`

  2. **.env.example**（所有变量小写 snake，pydantic 识别）:
     ```
     DB_URL=postgresql+psycopg://govcrawler:govcrawler@localhost:5432/govcrawler
     DATA_DIR=./data/govcrawler
     USER_AGENT=GovCrawlerBot/1.0 (contact: xxx@example.com)
     ```

  3. **.gitignore**：至少包含 `.env`、`data/`、`__pycache__/`、`*.pyc`、`.venv/`、`*.egg-info/`、`.pytest_cache/`、`dist/`、`build/`

  4. **docker-compose.yml**（仅 db service）：
     ```yaml
     services:
       db:
         image: postgres:16-alpine
         environment:
           POSTGRES_USER: govcrawler
           POSTGRES_PASSWORD: govcrawler
           POSTGRES_DB: govcrawler
         ports: ["5432:5432"]
         volumes: ["./data/pg:/var/lib/postgresql/data"]
         healthcheck:
           test: ["CMD-SHELL", "pg_isready -U govcrawler"]
           interval: 5s
           timeout: 3s
           retries: 10
     ```

  5. **README.md**（仅 setup 步骤，不写架构文档）：
     - Setup 三步：`uv sync` → `uv run patchright install chromium` → `docker compose up -d db` → `uv run alembic upgrade head`
     - 运行 CLI 示例：`uv run python -m govcrawler fetch gdqy szfwj post_2136593`（告知 Plan 02 会实现）

  6. **govcrawler/__init__.py**：`__version__ = "0.1.0"`

  7. **govcrawler/settings.py**：pydantic-settings v2 风格
     ```python
     from pydantic_settings import BaseSettings, SettingsConfigDict

     class Settings(BaseSettings):
         model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
         db_url: str
         data_dir: str = "./data/govcrawler"
         user_agent: str = "GovCrawlerBot/1.0 (contact: xxx@example.com)"

     def get_settings() -> Settings:
         return Settings()  # 每次读一次；本阶段量小不做 lru_cache
     ```

  8. **govcrawler/db.py**：
     ```python
     from sqlalchemy import create_engine
     from sqlalchemy.orm import sessionmaker, Session
     from govcrawler.settings import get_settings

     def get_engine():
         s = get_settings()
         return create_engine(s.db_url, echo=False, future=True, pool_pre_ping=True)

     def get_sessionmaker() -> sessionmaker[Session]:
         return sessionmaker(bind=get_engine(), expire_on_commit=False, class_=Session)
     ```

  决策/约束：
  - 依赖管理用 `uv`（per CONTEXT.md LOCKED）；pyproject.toml 与 uv 100% 兼容。
  - psycopg3 选 `psycopg[binary]` 避免编译 C（per user decision "LGPL-3 动态链接商用接受"）。
  - 加 `trafilatura>=2.0,<3` 进依赖（per additional_locked_decisions — GNE 失败时的二级兜底，Plan 02 使用）。
  - docker-compose 只起 db 一个服务；Phase 2 才加 valkey。
  </action>
  <verify>
    <automated>test -f pyproject.toml && test -f docker-compose.yml && test -f .env.example && test -f govcrawler/settings.py && test -f govcrawler/db.py && grep -q "patchright" pyproject.toml && grep -q "trafilatura" pyproject.toml && grep -q "psycopg" pyproject.toml && grep -q "postgres:16-alpine" docker-compose.yml && python -c "import ast; ast.parse(open('govcrawler/settings.py').read()); ast.parse(open('govcrawler/db.py').read())"</automated>
  </verify>
  <acceptance_criteria>
    - pyproject.toml 存在且 grep 到以下关键字：patchright, parsel, GeneralNewsExtractor, trafilatura, SQLAlchemy, alembic, psycopg, simhash, httpx, pydantic-settings, python-slugify, lxml
    - docker-compose.yml 存在且 grep 到 "postgres:16-alpine" 与 POSTGRES_USER=govcrawler
    - .env.example 存在且包含 DB_URL / DATA_DIR / USER_AGENT 三行
    - .gitignore 包含 `data/` 与 `.env`
    - govcrawler/settings.py 可被 python ast.parse 成功解析；包含 `class Settings`
    - govcrawler/db.py 可被 python ast.parse 成功解析；包含 `create_engine` 与 `sessionmaker`
    - README.md 包含 `uv sync` 与 `patchright install chromium` 两条字面命令
  </acceptance_criteria>
  <done>项目克隆后可按 README 3 步走完依赖 + PG + Chromium 准备（不含 alembic，Task 2 交付）。</done>
</task>

<task type="auto">
  <name>Task 2: SQLAlchemy ORM models + Alembic 初始迁移</name>
  <files>
    govcrawler/models.py,
    alembic.ini,
    alembic/env.py,
    alembic/script.py.mako,
    alembic/versions/0001_initial_schema.py
  </files>
  <read_first>
    - /Users/alphaPlanet/vibecoding/website/.planning/phases/01-poc/01-RESEARCH.md  (§Code Examples - Alembic 初始迁移)
    - /Users/alphaPlanet/vibecoding/website/.planning/phases/01-poc/01-CONTEXT.md  (§PG Schema LOCKED)
    - /Users/alphaPlanet/vibecoding/website/.planning/REQUIREMENTS.md  (STORE-04, STORE-05 字段要求)
    - https://alembic.sqlalchemy.org/en/latest/tutorial.html  (如需参考；已在 RESEARCH.md 摘录)
  </read_first>
  <action>
  创建 SQLAlchemy 2.0 DeclarativeBase 风格的 ORM + 配套 Alembic 初始迁移。**模型和 migration 字段必须 1:1 对齐**（后续任何改动都先改 model 再 `alembic revision --autogenerate`）。

  **1. govcrawler/models.py** — SQLAlchemy 2.0 `Mapped[...]` 风格：

  ```python
  from __future__ import annotations
  from datetime import datetime
  from sqlalchemy import (
      BigInteger, String, Text, Boolean, DateTime, CHAR, JSON,
      ForeignKey, Integer, Index, func,
  )
  from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship

  class Base(DeclarativeBase):
      pass

  class Site(Base):
      __tablename__ = "sites"
      id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
      site_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
      name: Mapped[str | None] = mapped_column(Text)
      base_url: Mapped[str | None] = mapped_column(Text)
      config_json: Mapped[dict | None] = mapped_column(JSON)
      enabled: Mapped[bool] = mapped_column(Boolean, server_default=func.true())
      created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

  class Column_(Base):   # 避开 SQL 关键字，类名后加下划线；__tablename__ 保持 "columns"
      __tablename__ = "columns"
      id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
      site_id: Mapped[str] = mapped_column(String(64), nullable=False)
      column_id: Mapped[str] = mapped_column(String(64), nullable=False)
      name: Mapped[str | None] = mapped_column(Text)
      category: Mapped[str | None] = mapped_column(String(64))
      list_url: Mapped[str | None] = mapped_column(Text)
      config_json: Mapped[dict | None] = mapped_column(JSON)
      last_crawled_at: Mapped[datetime | None] = mapped_column(DateTime)
      last_article_time: Mapped[datetime | None] = mapped_column(DateTime)
      enabled: Mapped[bool] = mapped_column(Boolean, server_default=func.true())
      __table_args__ = (Index("ix_columns_site_col", "site_id", "column_id", unique=True),)

  class Article(Base):
      __tablename__ = "articles"
      id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
      site_id: Mapped[str] = mapped_column(String(64), nullable=False)
      column_id: Mapped[str] = mapped_column(String(64), nullable=False)
      category: Mapped[str | None] = mapped_column(String(64))
      url: Mapped[str] = mapped_column(Text, nullable=False)
      url_hash: Mapped[str] = mapped_column(CHAR(64), nullable=False, unique=True)
      content_simhash: Mapped[str | None] = mapped_column(CHAR(16))
      title: Mapped[str | None] = mapped_column(Text)
      publish_time: Mapped[datetime | None] = mapped_column(DateTime)
      source: Mapped[str | None] = mapped_column(String(128))
      content_text: Mapped[str | None] = mapped_column(Text)
      raw_html_path: Mapped[str | None] = mapped_column(Text)
      text_path: Mapped[str | None] = mapped_column(Text)
      has_attachment: Mapped[bool] = mapped_column(Boolean, server_default=func.false())
      status: Mapped[str] = mapped_column(String(16), server_default="raw")  # raw|ready|failed
      fetch_strategy: Mapped[str | None] = mapped_column(String(32))
      fetched_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
      exported_to_rag_at: Mapped[datetime | None] = mapped_column(DateTime)
      __table_args__ = (
          Index("ix_articles_site_col_pub", "site_id", "column_id", "publish_time"),
          Index("ix_articles_exported", "exported_to_rag_at"),
      )

  class Attachment(Base):
      __tablename__ = "attachments"
      id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
      article_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("articles.id", ondelete="CASCADE"), nullable=False)
      file_name: Mapped[str | None] = mapped_column(Text)
      file_ext: Mapped[str | None] = mapped_column(String(16))
      size_bytes: Mapped[int | None] = mapped_column(BigInteger)
      file_path: Mapped[str | None] = mapped_column(Text)
      file_hash: Mapped[str | None] = mapped_column(CHAR(64))
      downloaded_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
      __table_args__ = (Index("ix_attachments_file_hash", "file_hash"),)

  class CrawlLog(Base):
      __tablename__ = "crawl_logs"
      id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
      site_id: Mapped[str | None] = mapped_column(String(64))
      column_id: Mapped[str | None] = mapped_column(String(64))
      article_url: Mapped[str | None] = mapped_column(Text)
      strategy: Mapped[str | None] = mapped_column(String(32))
      http_status: Mapped[int | None] = mapped_column(Integer)
      duration_ms: Mapped[int | None] = mapped_column(Integer)
      success: Mapped[bool | None] = mapped_column(Boolean)
      error_msg: Mapped[str | None] = mapped_column(Text)
      occurred_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  ```

  **2. alembic.ini** — 只保留关键节；sqlalchemy.url 留空（由 env.py 注入）：
  ```ini
  [alembic]
  script_location = alembic
  prepend_sys_path = .
  version_path_separator = os
  sqlalchemy.url =

  [loggers]
  keys = root,sqlalchemy,alembic

  [handlers]
  keys = console

  [formatters]
  keys = generic

  [logger_root]
  level = WARN
  handlers = console
  qualname =

  [logger_sqlalchemy]
  level = WARN
  handlers =
  qualname = sqlalchemy.engine

  [logger_alembic]
  level = INFO
  handlers =
  qualname = alembic

  [handler_console]
  class = StreamHandler
  args = (sys.stderr,)
  level = NOTSET
  formatter = generic

  [formatter_generic]
  format = %(levelname)-5.5s [%(name)s] %(message)s
  datefmt = %H:%M:%S
  ```

  **3. alembic/env.py** — 从 `govcrawler.settings` 读 db_url，从 `govcrawler.models.Base.metadata` 取 metadata。**同步模式**（psycopg3 sync per user decision）：
  ```python
  from __future__ import annotations
  from logging.config import fileConfig
  from sqlalchemy import engine_from_config, pool
  from alembic import context
  from govcrawler.settings import get_settings
  from govcrawler.models import Base

  config = context.config
  if config.config_file_name is not None:
      fileConfig(config.config_file_name)

  # 注入 db_url
  config.set_main_option("sqlalchemy.url", get_settings().db_url)
  target_metadata = Base.metadata

  def run_migrations_offline() -> None:
      url = config.get_main_option("sqlalchemy.url")
      context.configure(url=url, target_metadata=target_metadata, literal_binds=True,
                        dialect_opts={"paramstyle": "named"})
      with context.begin_transaction():
          context.run_migrations()

  def run_migrations_online() -> None:
      connectable = engine_from_config(
          config.get_section(config.config_ini_section, {}),
          prefix="sqlalchemy.",
          poolclass=pool.NullPool,
      )
      with connectable.connect() as connection:
          context.configure(connection=connection, target_metadata=target_metadata,
                            compare_type=True, compare_server_default=True)
          with context.begin_transaction():
              context.run_migrations()

  if context.is_offline_mode():
      run_migrations_offline()
  else:
      run_migrations_online()
  ```

  **4. alembic/script.py.mako** — 标准 Alembic 模板（从 `alembic init alembic` 默认生成的一致即可）：
  ```
  """${message}

  Revision ID: ${up_revision}
  Revises: ${down_revision | comma,n}
  Create Date: ${create_date}

  """
  from alembic import op
  import sqlalchemy as sa
  ${imports if imports else ""}

  revision = ${repr(up_revision)}
  down_revision = ${repr(down_revision)}
  branch_labels = ${repr(branch_labels)}
  depends_on = ${repr(depends_on)}


  def upgrade() -> None:
      ${upgrades if upgrades else "pass"}


  def downgrade() -> None:
      ${downgrades if downgrades else "pass"}
  ```

  **5. alembic/versions/0001_initial_schema.py** — 手写（不用 autogenerate，保证与 models.py 字段顺序一致）。必须创建五张表：sites, columns, articles, attachments, crawl_logs。字段与上面 models.py 精确对应。index 也一并创建。downgrade 按反向顺序 drop（attachments → crawl_logs → articles → columns → sites）。

  约束/注意：
  - CHAR(64) 用 `sa.CHAR(64)`；CHAR(16) 用 `sa.CHAR(16)`。
  - boolean server_default 用 `sa.text("true")` / `sa.text("false")`。
  - attachments.article_id 的外键加 `ondelete="CASCADE"`。
  - 所有索引在 migration 中显式 `op.create_index(...)`。
  - revision="0001_initial", down_revision=None。
  </action>
  <verify>
    <automated>python -c "import ast; [ast.parse(open(f).read()) for f in ['govcrawler/models.py','alembic/env.py','alembic/versions/0001_initial_schema.py']]" && grep -q "class Base(DeclarativeBase)" govcrawler/models.py && grep -q "class Article" govcrawler/models.py && grep -q "class Attachment" govcrawler/models.py && grep -q "class CrawlLog" govcrawler/models.py && grep -q "__tablename__ = \"columns\"" govcrawler/models.py && grep -q "create_table" alembic/versions/0001_initial_schema.py && grep -q "target_metadata = Base.metadata" alembic/env.py && grep -q "script_location = alembic" alembic.ini</automated>
  </verify>
  <acceptance_criteria>
    - govcrawler/models.py 通过 python -c "import ast; ast.parse(...)" 语法检查
    - models.py 包含 5 个类：Site、Column_（tablename=columns）、Article、Attachment、CrawlLog
    - models.py 中 Article 包含字段：url_hash（CHAR(64) unique）、content_simhash（CHAR(16)）、status（default 'raw'）、exported_to_rag_at
    - models.py 中 Attachment 包含 file_hash（CHAR(64)）和 FK 到 articles.id 且 ondelete=CASCADE
    - alembic.ini 包含 `script_location = alembic`
    - alembic/env.py 从 `govcrawler.settings.get_settings()` 读 db_url 并设置到 config；target_metadata = Base.metadata
    - alembic/versions/0001_initial_schema.py: revision='0001_initial', down_revision=None, 包含 5 个 op.create_table 调用和至少 3 个 op.create_index，downgrade 反向 drop 5 张表
    - 端到端手工验证（可由执行者在本地跑一次）：`docker compose up -d db && sleep 5 && uv run alembic upgrade head` 能成功，然后 `uv run python -c "from sqlalchemy import inspect; from govcrawler.db import get_engine; print(sorted(inspect(get_engine()).get_table_names()))"` 输出包含 'articles','attachments','columns','crawl_logs','sites'
    - `uv run alembic downgrade base` 能成功清空全部表
  </acceptance_criteria>
  <done>
    五张 PG 表可通过 alembic 重建。STORE-04/05 建表部分完成；写入部分由 Plan 02 实现。
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Filesystem → DB | DB_URL 通过 .env 注入；不在代码中硬编码 |
| 开发者 shell → PG container | docker-compose 暴露 5432 到 localhost 仅（非 0.0.0.0）|

## STRIDE Threat Register

> `security_enforcement` 未显式开启；本 plan 仍按最低标准填写。

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-01-01-01 | Information Disclosure | .env 泄漏 DB 密码 | mitigate | .gitignore 明确列出 `.env`；仅提交 `.env.example` |
| T-01-01-02 | Tampering | SQL Injection via URL / title 等用户/抓取输入 | mitigate | 全部通过 SQLAlchemy ORM 参数化，Plan 02 执行器严禁使用 raw SQL 拼字符串 |
| T-01-01-03 | Elevation of Privilege | psycopg3 LGPL-3 许可证合规风险 | accept | 用户在 additional_locked_decisions 中已接受 "LGPL-3 动态链接商用"，不修改 psycopg3 源码，仅 pip 动态链接 |
| T-01-01-04 | DoS | 本地 PG 未限连接数 → 开发机易被耗尽 | accept | 本地开发场景；Phase 3 部署时再加 pool 限制 |
</threat_model>

<verification>
- Task 1: 骨架文件齐全且语法可解析
- Task 2: migration + models 对齐；alembic upgrade/downgrade 双向可跑
- 端到端（开发者手工或 CI 跑）：
  1. `uv sync` 成功
  2. `uv run patchright install chromium` 成功（下载约 150MB）
  3. `docker compose up -d db` 成功，`docker compose ps` 显示 healthy
  4. `uv run alembic upgrade head` 输出 "Running upgrade -> 0001_initial"
  5. `psql postgresql://govcrawler:govcrawler@localhost:5432/govcrawler -c "\dt"` 列出 5 张表
  6. `uv run alembic downgrade base` 清空
</verification>

<success_criteria>
- [ ] pyproject.toml 所有依赖可解析，uv sync 无冲突
- [ ] docker compose up -d db 能启动 PG16 且 healthcheck 通过
- [ ] alembic upgrade head 能在空 PG 上创建 sites/columns/articles/attachments/crawl_logs 五表
- [ ] alembic downgrade base 能干净回滚
- [ ] SQLAlchemy ORM models 可 `from govcrawler.models import Base, Site, Article, Attachment, CrawlLog, Column_` 无 ImportError
- [ ] Settings 可 `from govcrawler.settings import get_settings; get_settings().db_url` 返回 DB_URL
</success_criteria>

<output>
完成后创建 `.planning/phases/01-poc/01-01-SUMMARY.md`，记录：
- 实际依赖版本（uv.lock 的精确版本）
- alembic migration 路径与 revision hash
- 任何偏离 RESEARCH.md 的决策与原因
- 已验证的端到端命令序列
</output>
</content>
</invoke>