diff --git a/docs/superpowers/plans/2026-05-09-fastapi-docker-migration.md b/docs/superpowers/plans/2026-05-09-fastapi-docker-migration.md new file mode 100644 index 0000000..db252be --- /dev/null +++ b/docs/superpowers/plans/2026-05-09-fastapi-docker-migration.md @@ -0,0 +1,2760 @@ +# FastAPI + Docker 迁移实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将广西政府采购网公告监控系统从 Flask CLI 架构迁移到 FastAPI + Docker,含 Spider 模块化、SQLAlchemy ORM、APScheduler 和异步改造。 + +**Architecture:** 单体 FastAPI 应用,Spider 基类 + Pipeline 策略模式统一爬虫管理,PostgreSQL + asyncpg 异步数据库,APScheduler 内置定时任务,Docker 单容器部署连接外部 PostgreSQL。 + +**Tech Stack:** Python 3.12, FastAPI, SQLAlchemy 2.0 (async), asyncpg, httpx, APScheduler, pydantic-settings, Alembic, pytest, ruff, Docker, BeautifulSoup4, pycryptodome + +--- + +### Task 1: 创建项目骨架 + +**Files:** +- Create: `pyproject.toml` +- Create: `app/__init__.py` +- Create: `app/config.py` +- Create: `app/main.py` +- Create: `.env.example` +- Create: `.gitignore` + +- [ ] **Step 1: 创建 pyproject.toml** + +```toml +[project] +name = "gx-gp-notify" +version = "2.0.0" +description = "广西政府采购网公告监控系统" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "sqlalchemy[asyncio]>=2.0.30", + "asyncpg>=0.29.0", + "alembic>=1.13.0", + "httpx>=0.27.0", + "apscheduler>=3.10.0", + "pydantic-settings>=2.3.0", + "beautifulsoup4>=4.12.0", + "lxml>=5.2.0", + "pycryptodome>=3.20.0", + "python-dateutil>=2.9.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.2.0", + "pytest-asyncio>=0.23.0", + "pytest-cov>=5.0.0", + "httpx>=0.27.0", + "ruff>=0.4.0", +] + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +``` + +- [ ] **Step 2: 创建 .env.example** + +```bash +# 应用 +DEBUG=false +LOG_LEVEL=INFO + +# 数据库 +DATABASE_URL=postgresql+asyncpg://gx-gp-notify:password@10.10.10.14:5432/gx-gp-notify + +# 爬虫 +CRAWLER_BASE_URL=https://zfcg.gxzf.gov.cn +CRAWLER_KEYWORDS=["大化"] +CRAWLER_MAX_PAGES=10 +CRAWLER_TIMEOUT=30 + +# 企业微信 +WECHAT_ENABLED=true +WECHAT_CORP_ID=ww69e8e44636f47780 +WECHAT_AGENT_ID=1000007 +WECHAT_SECRET= +WECHAT_TOKEN= +WECHAT_ENCODING_AES_KEY= +WECHAT_PORT=18001 +WECHAT_HOST=0.0.0.0 + +# 定时任务 +SCHEDULER_ENABLED=true +SCHEDULER_CRON=0 8,14,18 * * * + +# Markdown +MARKDOWN_ENABLED=true +MARKDOWN_OUTPUT_FILE=onu.md +``` + +- [ ] **Step 3: 创建 .gitignore** + +```gitignore +.env +logs/ +*.log +__pycache__/ +*.pyc +.venv/ +.ruff_cache/ +.pytest_cache/ +*.egg-info/ +dist/ +``` + +- [ ] **Step 4: 创建 app/__init__.py** (空文件) + +- [ ] **Step 5: 创建 app/config.py** + +```python +from pydantic_settings import BaseSettings, SettingsConfigDict +from typing import List + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + + # 应用 + debug: bool = False + log_level: str = "INFO" + + # 数据库 + database_url: str = "postgresql+asyncpg://gx-gp-notify:password@localhost:5432/gx-gp-notify" + + # 爬虫 + crawler_base_url: str = "https://zfcg.gxzf.gov.cn" + crawler_keywords: List[str] = ["大化"] + crawler_max_pages: int = 10 + crawler_timeout: int = 30 + crawler_page_size: int = 100 + + # 企业微信 + wechat_enabled: bool = True + wechat_corp_id: str = "" + wechat_agent_id: str = "" + wechat_secret: str = "" + wechat_token: str = "" + wechat_encoding_aes_key: str = "" + wechat_port: int = 18001 + wechat_host: str = "0.0.0.0" + + # 定时任务 + scheduler_enabled: bool = True + scheduler_cron: str = "0 8,14,18 * * *" + + # Markdown + markdown_enabled: bool = True + markdown_output_file: str = "onu.md" + + # 公告来源(JSON 字符串,从环境变量读取) + announcement_sources: str = '{"ZcyAnnouncement1":{"category_id":66485,"name":"采购公告","type":"purchase"},"ZcyAnnouncement2":{"category_id":66485,"name":"结果公告","type":"result"},"ZcyAnnouncement3":{"category_id":66485,"name":"合同公告","type":"contract"},"ZcyAnnouncement4":{"category_id":66485,"name":"更正公告","type":"correction"},"ZcyAnnouncement5":{"category_id":66485,"name":"招标文件预公示","type":"pre_announcement"},"ZcyAnnouncement6":{"category_id":66485,"name":"单一来源公示","type":"single_source"},"ZcyAnnouncement7":{"category_id":66485,"name":"电子卖场公示","type":"electronic_market"},"ZcyAnnouncement10":{"category_id":66485,"name":"履约验收公示","type":"acceptance"},"ZcyAnnouncement11":{"category_id":66485,"name":"工程类公告","type":"engineering"},"ZcyAnnouncement20":{"category_id":66485,"name":"框架协议征集公告","type":"framework_agreement"},"ZcyAnnouncement21":{"category_id":66485,"name":"框架协议入围结果公告","type":"framework_result"},"ZcyAnnouncement23":{"category_id":66485,"name":"框架协议成交结果汇总公告","type":"framework_summary"},"61-266648":{"category_id":66485,"name":"采购意向公开","type":"intention"}}' + + +settings = Settings() +``` + +- [ ] **Step 6: 创建 app/main.py** + +```python +import logging +from contextlib import asynccontextmanager +from fastapi import FastAPI +from app.config import settings + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # 启动时初始化 + logging.basicConfig(level=settings.log_level) + yield + # 关闭时清理 + + +app = FastAPI( + title="广西政府采购网公告监控系统", + version="2.0.0", + lifespan=lifespan, + docs_url="/docs" if settings.debug else None, + redoc_url=None, +) + + +@app.get("/health") +async def health(): + return {"status": "ok"} +``` + +- [ ] **Step 7: 安装依赖并验证** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +pip install -e ".[dev]" +uvicorn app.main:app --host 0.0.0.0 --port 8000 & +sleep 2 +curl http://localhost:8000/health +# Expected: {"status":"ok"} +kill %1 +``` + +- [ ] **Step 8: 提交** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +git add pyproject.toml app/__init__.py app/config.py app/main.py .env.example .gitignore +git commit -m "feat: 创建 FastAPI 项目骨架(config + main + 依赖管理)" +``` + +--- + +### Task 2: 数据库模型 + Alembic + +**Files:** +- Create: `app/models/__init__.py` +- Create: `app/models/announcement.py` +- Create: `app/models/schemas.py` +- Create: `alembic.ini` +- Create: `alembic/env.py` +- Create: `alembic/script.py.mako` +- Modify: `app/main.py` — 添加数据库引擎 + +- [ ] **Step 1: 创建 app/models/__init__.py** (空文件) + +- [ ] **Step 2: 创建 app/models/announcement.py** + +```python +import hashlib +from datetime import datetime, date +from sqlalchemy import String, Boolean, DateTime, Integer, Text, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class Announcement(Base): + __tablename__ = "announcements" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + title: Mapped[str] = mapped_column(String(500), nullable=False) + publish_date: Mapped[datetime] = mapped_column(DateTime, nullable=False) + purchase_name: Mapped[str] = mapped_column(String(200), default="") + content_url: Mapped[str] = mapped_column(Text, default="") + source_code: Mapped[str] = mapped_column(String(50), nullable=False) + source_name: Mapped[str] = mapped_column(String(100), nullable=False) + announcement_type: Mapped[str] = mapped_column(String(50), default="purchase") + content_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + crawl_mode: Mapped[str] = mapped_column(String(20), default="auto") + is_new: Mapped[bool] = mapped_column(Boolean, default=True) + is_sent: Mapped[bool] = mapped_column(Boolean, default=False) + keyword_matched: Mapped[bool] = mapped_column(Boolean, default=False) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now()) + + @staticmethod + def generate_hash(title: str, publish_date: str, purchase_name: str, + content_url: str, source_code: str) -> str: + content = f"{title}|{publish_date}|{purchase_name}|{content_url}|{source_code}" + return hashlib.sha256(content.encode("utf-8")).hexdigest() + + @staticmethod + def source_map() -> dict: + import json + from app.config import settings + return json.loads(settings.announcement_sources) +``` + +- [ ] **Step 3: 创建 app/models/schemas.py** + +```python +from datetime import datetime +from typing import Optional +from pydantic import BaseModel + + +class AnnouncementResponse(BaseModel): + id: int + title: str + publish_date: datetime + purchase_name: str + content_url: str + source_code: str + source_name: str + announcement_type: str + crawl_mode: str + is_new: bool + is_sent: bool + keyword_matched: bool + created_at: datetime + + model_config = {"from_attributes": True} + + +class AnnouncementListResponse(BaseModel): + total: int + page: int + page_size: int + items: list[AnnouncementResponse] + + +class CrawlTriggerRequest(BaseModel): + keywords: Optional[list[str]] = None + sources: Optional[list[str]] = None + manual: bool = False + + +class CrawlStatusResponse(BaseModel): + running: bool + last_crawl_time: Optional[datetime] = None + total_sources: int + + +class SourceInfo(BaseModel): + code: str + name: str + type: str + + +class SourcesResponse(BaseModel): + sources: list[SourceInfo] + + +class JobResponse(BaseModel): + id: str + name: str + next_run_time: Optional[str] = None +``` + +- [ ] **Step 4: 初始化 Alembic** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +pip install alembic +alembic init alembic +``` + +- [ ] **Step 5: 修改 alembic/env.py** + +Read the generated file, replace its content with: + +```python +from alembic import context +from sqlalchemy import engine_from_config, pool +from app.models.announcement import Base + +config = context.config +config.set_main_option("sqlalchemy.url", "postgresql+asyncpg://placeholder:placeholder@localhost:5432/placeholder") + +target_metadata = Base.metadata + + +def run_migrations_offline(): + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + from app.config import settings + connectable = engine_from_config( + {"sqlalchemy.url": settings.database_url}, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() +``` + +- [ ] **Step 6: 生成初始迁移** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +DATABASE_URL=postgresql+asyncpg://gx-gp-notify:MA6RBX4F6Bd5DGmw@10.10.10.14:5432/gx-gp-notify \ + alembic revision --autogenerate -m "create_announcements_table" +# Expected: Generating .../alembic/versions/xxxx_create_announcements_table.py ... done +``` + +- [ ] **Step 7: 运行迁移** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +DATABASE_URL=postgresql+asyncpg://gx-gp-notify:MA6RBX4F6Bd5DGmw@10.10.10.14:5432/gx-gp-notify \ + alembic upgrade head +# Expected: Running upgrade ... -> xxxx, create announcements table +``` + +- [ ] **Step 8: 更新 app/main.py 添加数据库初始化** + +```python +import logging +from contextlib import asynccontextmanager +from fastapi import FastAPI +from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession +from app.config import settings +from app.models.announcement import Base + +engine = create_async_engine(settings.database_url, echo=settings.debug) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_db() -> AsyncSession: + async with async_session() as session: + yield session + + +@asynccontextmanager +async def lifespan(app: FastAPI): + logging.basicConfig( + level=getattr(logging, settings.log_level), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + yield + await engine.dispose() + + +app = FastAPI( + title="广西政府采购网公告监控系统", + version="2.0.0", + lifespan=lifespan, + docs_url="/docs" if settings.debug else None, + redoc_url=None, +) + + +@app.get("/health") +async def health(): + return {"status": "ok"} +``` + +- [ ] **Step 9: 提交** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +git add app/models/ alembic/ alembic.ini app/main.py +git commit -m "feat: 添加 SQLAlchemy 模型 + Alembic 数据库迁移" +``` + +--- + +### Task 3: Spider 基类 + Pipeline 配置 + +**Files:** +- Create: `app/crawler/__init__.py` +- Create: `app/crawler/base.py` +- Test: `tests/test_crawler/test_base.py` +- Create: `tests/__init__.py` +- Create: `tests/test_crawler/__init__.py` +- Create: `tests/conftest.py` + +- [ ] **Step 1: 创建 app/crawler/__init__.py** (空文件) + +- [ ] **Step 2: 创建 app/crawler/base.py** + +```python +import hashlib +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from datetime import datetime +from typing import List, Optional + + +@dataclass +class CrawlResult: + source_code: str + source_name: str + total_count: int = 0 + new_count: int = 0 + announcements: list = field(default_factory=list) + error_message: Optional[str] = None + crawled_at: datetime = field(default_factory=datetime.now) + duration: float = 0.0 + + @property + def success(self) -> bool: + return self.error_message is None + + +@dataclass +class PipelineConfig: + filter_enabled: bool = True + keywords: List[str] = field(default_factory=list) + dedup_enabled: bool = True + notify_mode: str = "filtered" + mark_sent: bool = False + + +@dataclass +class PipelineResult: + stored: int = 0 + filtered: int = 0 + notified: int = 0 + markdown_generated: bool = False + + +class BaseSpider(ABC): + name: str + source_code: str + source_name: str + + @abstractmethod + async def crawl(self) -> CrawlResult: + ... + + def get_pipeline_config(self) -> PipelineConfig: + return PipelineConfig() + + @staticmethod + def generate_content_hash(title: str, publish_date: str, purchase_name: str, + content_url: str, source_code: str) -> str: + content = f"{title}|{publish_date}|{purchase_name}|{content_url}|{source_code}" + return hashlib.sha256(content.encode("utf-8")).hexdigest() +``` + +- [ ] **Step 3: 创建 tests/__init__.py, tests/test_crawler/__init__.py** (空文件) + +- [ ] **Step 4: 创建 tests/conftest.py** + +```python +import pytest + + +@pytest.fixture +def sample_announcement_data(): + return { + "title": "测试公告标题", + "publish_date": "2026-05-09", + "purchase_name": "测试单位", + "content_url": "https://example.com/detail/123", + "source_code": "ZcyAnnouncement1", + "source_name": "采购公告", + } +``` + +- [ ] **Step 5: 创建 tests/test_crawler/test_base.py** + +```python +import pytest +from datetime import datetime +from app.crawler.base import BaseSpider, CrawlResult, PipelineConfig, PipelineResult + + +class FakeSpider(BaseSpider): + name = "test_spider" + source_code = "test_source" + source_name = "测试来源" + + async def crawl(self) -> CrawlResult: + return CrawlResult( + source_code=self.source_code, + source_name=self.source_name, + total_count=5, + new_count=3, + crawled_at=datetime.now(), + ) + + def get_pipeline_config(self) -> PipelineConfig: + return PipelineConfig( + filter_enabled=True, + keywords=["测试"], + dedup_enabled=True, + notify_mode="filtered", + mark_sent=False, + ) + + +@pytest.mark.asyncio +async def test_base_spider_crawl(): + spider = FakeSpider() + result = await spider.crawl() + assert result.source_code == "test_source" + assert result.total_count == 5 + assert result.new_count == 3 + assert result.success is True + + +def test_pipeline_config_defaults(): + config = PipelineConfig() + assert config.filter_enabled is True + assert config.keywords == [] + assert config.dedup_enabled is True + assert config.notify_mode == "filtered" + assert config.mark_sent is False + + +def test_pipeline_result_defaults(): + result = PipelineResult() + assert result.stored == 0 + assert result.filtered == 0 + assert result.notified == 0 + assert result.markdown_generated is False + + +def test_generate_content_hash(): + h = BaseSpider.generate_content_hash( + "title", "2026-05-09", "unit", "https://x.com", "source" + ) + assert len(h) == 64 + assert all(c in "0123456789abcdef" for c in h) + + +def test_generate_content_hash_deterministic(): + h1 = BaseSpider.generate_content_hash("t", "d", "p", "u", "s") + h2 = BaseSpider.generate_content_hash("t", "d", "p", "u", "s") + assert h1 == h2 + + +def test_crawl_result_failure(): + result = CrawlResult( + source_code="s", source_name="n", + error_message="连接超时" + ) + assert result.success is False +``` + +- [ ] **Step 6: 运行测试** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +python -m pytest tests/test_crawler/test_base.py -v +# Expected: 6 passed +``` + +- [ ] **Step 7: 提交** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +git add app/crawler/ tests/ +git commit -m "feat: 添加 Spider 基类 + Pipeline 配置 + 测试" +``` + +--- + +### Task 4: Parssers 模块 + +**Files:** +- Create: `app/crawler/parsers.py` +- Test: `tests/test_crawler/test_parsers.py` + +- [ ] **Step 1: 创建测试 tests/test_crawler/test_parsers.py** + +```python +import json +from datetime import datetime +from app.crawler.parsers import ( + parse_gxgp_api_response, + parse_dahuagov_html, + extract_pagination, +) + + +def make_api_response(records_data): + return { + "success": True, + "result": { + "data": { + "data": records_data, + "total": len(records_data), + "pageNo": 1, + "pageSize": 100, + "pages": 1, + "empty": len(records_data) == 0, + "hasNext": False, + "hasPrevious": False, + } + }, + } + + +def test_parse_gxgp_single_record(): + response = make_api_response([ + { + "title": "测试采购公告", + "publishDate": 1746720000000, + "purchaseName": "测试采购单位", + "articleId": 12345, + } + ]) + crawled_at = datetime(2026, 5, 9, 10, 0, 0) + results = parse_gxgp_api_response( + response, + source_code="ZcyAnnouncement1", + source_name="采购公告", + crawled_at=crawled_at, + category_id=66485, + ) + assert len(results) == 1 + assert results[0]["title"] == "测试采购公告" + assert results[0]["source_code"] == "ZcyAnnouncement1" + assert "content_hash" in results[0] + + +def test_parse_gxgp_empty_response(): + response = make_api_response([]) + crawled_at = datetime(2026, 5, 9, 10, 0, 0) + results = parse_gxgp_api_response( + response, "ZcyAnnouncement1", "采购公告", crawled_at, 66485 + ) + assert len(results) == 0 + + +def test_parse_gxgp_missing_title(): + response = make_api_response([ + {"title": "", "publishDate": 1746720000000, "purchaseName": "x", "articleId": 1} + ]) + crawled_at = datetime(2026, 5, 9, 10, 0, 0) + results = parse_gxgp_api_response( + response, "ZcyAnnouncement1", "采购公告", crawled_at, 66485 + ) + assert len(results) == 0 + + +def test_extract_pagination(): + response = make_api_response([]) + pagination = extract_pagination(response) + assert pagination["total"] == 0 + assert pagination["page_no"] == 1 + assert pagination["has_next"] is False + + +def test_parse_dahuagov_html(): + html = """ + + + + """ + crawled_at = datetime(2026, 5, 9, 10, 0, 0) + results = parse_dahuagov_html(html, crawled_at) + assert len(results) == 2 + assert results[0]["title"] == "大化县某项目采购公告" + assert results[0]["source_code"] == "dahuagov" + assert results[0]["source_name"] == "大化县政府网采购公告" + assert results[0]["purchase_name"] == "大化瑶族自治县" + + +def test_parse_dahuagov_html_no_list(): + html = "" + crawled_at = datetime(2026, 5, 9, 10, 0, 0) + results = parse_dahuagov_html(html, crawled_at) + assert len(results) == 0 +``` + +- [ ] **Step 2: 运行测试确认失败** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +python -m pytest tests/test_crawler/test_parsers.py -v +# Expected: FAIL — ModuleNotFoundError or ImportError +``` + +- [ ] **Step 3: 创建 app/crawler/parsers.py** + +```python +import hashlib +from datetime import datetime +from typing import Any, Dict, List, Optional +from urllib.parse import urljoin +from bs4 import BeautifulSoup + + +def parse_gxgp_api_response( + response_data: Dict[str, Any], + source_code: str, + source_name: str, + crawled_at: datetime, + category_id: int, +) -> List[Dict[str, Any]]: + if not response_data.get("success"): + return [] + data = response_data.get("result", {}).get("data", {}) + records = data.get("data", []) + if not records: + return [] + + results = [] + for record in records: + title = str(record.get("title", "")).strip() + if not title: + continue + + timestamp = record.get("publishDate") + if not timestamp: + continue + try: + publish_date = datetime.fromtimestamp(int(timestamp) / 1000) + except (ValueError, TypeError): + continue + + purchase_name = str(record.get("purchaseName", "")).strip() + article_id = record.get("articleId") + if not article_id: + continue + + content_url = f"https://zfcg.gxzf.gov.cn/site/detail?parentId={category_id}&articleId={article_id}" + + announce = { + "title": title, + "publish_date": publish_date, + "purchase_name": purchase_name, + "content_url": content_url, + "source_code": source_code, + "source_name": source_name, + "announcement_type": "purchase", + "crawl_mode": "auto", + "is_new": True, + "is_today": publish_date.date() == datetime.now().date(), + } + announce["content_hash"] = _generate_hash(announce) + results.append(announce) + + return results + + +def extract_pagination(response_data: Dict[str, Any]) -> Dict[str, Any]: + data = response_data.get("result", {}).get("data", {}) + return { + "total": data.get("total", 0), + "page_no": data.get("pageNo", 1), + "page_size": data.get("pageSize", 100), + "pages": data.get("pages", 0), + "empty": data.get("empty", True), + "has_next": data.get("hasNext", False), + "has_previous": data.get("hasPrevious", False), + } + + +def parse_dahuagov_html(html: str, crawled_at: datetime) -> List[Dict[str, Any]]: + soup = BeautifulSoup(html, "html.parser") + lists = soup.find_all("ul", class_="more-list") + if not lists: + return [] + + results = [] + base_url = "http://www.gxdh.gov.cn" + base_path = "/xxgk/zdlyxxgk/ggzypzly/zfcgly/cggg/" + + for ul in lists: + for li in ul.find_all("li"): + date_span = li.find("span") + if not date_span: + continue + date_text = date_span.get_text(strip=True) + try: + publish_date = datetime.strptime(date_text, "%Y-%m-%d") + except ValueError: + continue + + link_tag = li.find("a") + if not link_tag: + continue + title = link_tag.get("title", "") or link_tag.get_text(strip=True) + href = link_tag.get("href", "") + if not title or not href: + continue + + if href.startswith("./") or href.startswith("../"): + content_url = urljoin(base_url + base_path, href) + elif href.startswith("/"): + content_url = base_url + href + elif href.startswith("http"): + content_url = href + else: + content_url = urljoin(base_url + base_path, href) + + announce = { + "title": title, + "publish_date": publish_date, + "purchase_name": "大化瑶族自治县", + "content_url": content_url, + "source_code": "dahuagov", + "source_name": "大化县政府网采购公告", + "announcement_type": "purchase", + "crawl_mode": "auto", + "is_new": True, + "is_today": publish_date.date() == datetime.now().date(), + } + announce["content_hash"] = _generate_hash(announce) + results.append(announce) + + return results + + +def _generate_hash(ann: Dict[str, Any]) -> str: + content = ( + f"{ann['title']}|{ann['publish_date'].strftime('%Y-%m-%d')}" + f"|{ann['purchase_name']}|{ann['content_url']}|{ann['source_code']}" + ) + return hashlib.sha256(content.encode("utf-8")).hexdigest() +``` + +- [ ] **Step 4: 运行测试验证通过** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +python -m pytest tests/test_crawler/test_parsers.py -v +# Expected: 6 passed +``` + +- [ ] **Step 5: 运行全部测试** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +python -m pytest tests/ -v +# Expected: 12 passed +``` + +- [ ] **Step 6: 提交** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +git add app/crawler/parsers.py tests/test_crawler/test_parsers.py +git commit -m "feat: 添加数据解析器模块 + 测试" +``` + +--- + +### Task 5: GXGP Spider(广西政府采购网) + +**Files:** +- Create: `app/crawler/gxgp_spider.py` +- Test: `tests/test_crawler/test_gxgp_spider.py` + +- [ ] **Step 1: 创建测试 tests/test_crawler/test_gxgp_spider.py** + +```python +import pytest +from datetime import datetime +from unittest.mock import AsyncMock, patch, MagicMock +from app.crawler.gxgp_spider import GXGPSpider +from app.crawler.base import PipelineConfig + + +@pytest.mark.asyncio +async def test_gxgp_spider_attributes(): + spider = GXGPSpider() + assert spider.name == "gxgp" + assert spider.source_code == "gxgp" + assert spider.source_name == "广西政府采购网" + + +@pytest.mark.asyncio +async def test_gxgp_spider_pipeline_config(): + spider = GXGPSpider() + config = spider.get_pipeline_config() + assert isinstance(config, PipelineConfig) + assert config.filter_enabled is True + assert config.notify_mode == "filtered" + assert config.mark_sent is False + + +@pytest.mark.asyncio +async def test_gxgp_spider_crawl_empty(): + spider = GXGPSpider() + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "success": True, + "result": {"data": {"data": [], "total": 0, "pageNo": 1, "pageSize": 100, + "pages": 0, "empty": True, "hasNext": False, "hasPrevious": False}}, + } + + with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_response)): + result = await spider.crawl() + assert result.total_count == 0 + assert len(result.announcements) == 0 + assert result.success is True + + +@pytest.mark.asyncio +async def test_gxgp_spider_crawl_with_data(): + spider = GXGPSpider() + + mock_data = { + "success": True, + "result": { + "data": { + "data": [{ + "title": "测试采购公告", + "publishDate": 1746720000000, + "purchaseName": "测试单位", + "articleId": 12345, + }], + "total": 1, "pageNo": 1, "pageSize": 100, + "pages": 1, "empty": False, "hasNext": False, "hasPrevious": False, + } + }, + } + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = mock_data + + with patch("httpx.AsyncClient.post", AsyncMock(return_value=mock_response)): + result = await spider.crawl(sources=["ZcyAnnouncement1"]) + assert result.total_count >= 0 + assert result.success is True +``` + +- [ ] **Step 2: 运行测试确认失败** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +python -m pytest tests/test_crawler/test_gxgp_spider.py -v +# Expected: FAIL — ImportError +``` + +- [ ] **Step 3: 创建 app/crawler/gxgp_spider.py** + +```python +import json +import random +import time +from datetime import datetime +from typing import List, Optional +import httpx +from app.config import settings +from app.crawler.base import BaseSpider, CrawlResult, PipelineConfig +from app.crawler.parsers import parse_gxgp_api_response, extract_pagination + + +class GXGPSpider(BaseSpider): + name = "gxgp" + source_code = "gxgp" + source_name = "广西政府采购网" + + def __init__(self): + self.base_url = settings.crawler_base_url + self.announcement_api = f"{self.base_url}/portal/category" + + def get_pipeline_config(self) -> PipelineConfig: + return PipelineConfig( + filter_enabled=True, + keywords=list(settings.crawler_keywords), + dedup_enabled=True, + notify_mode="filtered", + mark_sent=False, + ) + + async def crawl(self, sources: Optional[List[str]] = None, + max_pages: Optional[int] = None) -> CrawlResult: + if max_pages is None: + max_pages = settings.crawler_max_pages + if sources is None: + source_map = json.loads(settings.announcement_sources) + sources = list(source_map.keys()) + + start_time = datetime.now() + all_announcements = [] + error_messages = [] + + async with httpx.AsyncClient(timeout=settings.crawler_timeout) as client: + for source_code in sources: + source_info = json.loads(settings.announcement_sources).get(source_code) + if not source_info: + continue + + category_id = source_info["category_id"] + source_name = source_info["name"] + + for page_no in range(1, max_pages + 1): + if page_no > 1: + await self._delay() + + try: + data = await self._fetch_page( + client, source_code, category_id, page_no + ) + if data is None: + break + + records = parse_gxgp_api_response( + data, source_code, source_name, + start_time, category_id + ) + if not records: + break + + all_announcements.extend(records) + + pagination = extract_pagination(data) + if not pagination["has_next"]: + break + except Exception as e: + error_messages.append(f"{source_code} page {page_no}: {e}") + break + + duration = (datetime.now() - start_time).total_seconds() + return CrawlResult( + source_code=self.source_code, + source_name=self.source_name, + total_count=len(all_announcements), + new_count=len(all_announcements), + announcements=all_announcements, + error_message="; ".join(error_messages) if error_messages else None, + crawled_at=start_time, + duration=duration, + ) + + async def _fetch_page(self, client: httpx.AsyncClient, source_code: str, + category_id: int, page_no: int) -> Optional[dict]: + payload = { + "keyword": "", + "publishDateBegin": "", + "publishDateEnd": "", + "pageNo": page_no, + "pageSize": settings.crawler_page_size, + "categoryCode": source_code, + "_t": int(time.time() * 1000), + } + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Content-Type": "application/json;charset=UTF-8", + "Origin": self.base_url, + "Referer": f"{self.base_url}/site/category?parentId={category_id}&childrenCode={source_code}", + } + response = await client.post( + self.announcement_api, json=payload, headers=headers + ) + if response.status_code != 200: + return None + return response.json() + + async def _delay(self): + import asyncio + delay = random.uniform(1.0, 3.0) + await asyncio.sleep(delay) +``` + +- [ ] **Step 4: 运行测试验证通过** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +python -m pytest tests/test_crawler/test_gxgp_spider.py -v +# Expected: 3 passed +``` + +- [ ] **Step 5: 提交** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +git add app/crawler/gxgp_spider.py tests/test_crawler/test_gxgp_spider.py +git commit -m "feat: 添加 GXGP Spider(广西政府采购网爬虫)+ 测试" +``` + +--- + +### Task 6: Dahuagov Spider(大化县政府网) + +**Files:** +- Create: `app/crawler/dahuagov_spider.py` +- Test: `tests/test_crawler/test_dahuagov_spider.py` + +- [ ] **Step 1: 创建测试 tests/test_crawler/test_dahuagov_spider.py** + +```python +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from app.crawler.dahuagov_spider import DahuagovSpider +from app.crawler.base import PipelineConfig + + +@pytest.mark.asyncio +async def test_dahuagov_spider_attributes(): + spider = DahuagovSpider() + assert spider.name == "dahuagov" + assert spider.source_code == "dahuagov" + assert spider.source_name == "大化县政府网采购公告" + + +@pytest.mark.asyncio +async def test_dahuagov_spider_pipeline_config(): + spider = DahuagovSpider() + config = spider.get_pipeline_config() + assert isinstance(config, PipelineConfig) + assert config.filter_enabled is False + assert config.notify_mode == "all" + assert config.mark_sent is True + + +@pytest.mark.asyncio +async def test_dahuagov_spider_crawl(): + spider = DahuagovSpider() + + html = """ + + + + """ + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = html + + with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_response)): + result = await spider.crawl() + assert result.total_count == 2 + assert len(result.announcements) == 2 + assert result.success is True + + +@pytest.mark.asyncio +async def test_dahuagov_spider_crawl_empty(): + spider = DahuagovSpider() + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = "" + + with patch("httpx.AsyncClient.get", AsyncMock(return_value=mock_response)): + result = await spider.crawl() + assert result.total_count == 0 + assert result.success is True +``` + +- [ ] **Step 2: 运行测试确认失败** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +python -m pytest tests/test_crawler/test_dahuagov_spider.py -v +# Expected: FAIL — ImportError +``` + +- [ ] **Step 3: 创建 app/crawler/dahuagov_spider.py** + +```python +import asyncio +import random +from datetime import datetime +import httpx +from app.config import settings +from app.crawler.base import BaseSpider, CrawlResult, PipelineConfig +from app.crawler.parsers import parse_dahuagov_html + + +class DahuagovSpider(BaseSpider): + name = "dahuagov" + source_code = "dahuagov" + source_name = "大化县政府网采购公告" + + BASE_URL = "http://www.gxdh.gov.cn" + ANNOUNCEMENT_PATH = "/xxgk/zdlyxxgk/ggzypzly/zfcgly/cggg/" + + def get_pipeline_config(self) -> PipelineConfig: + return PipelineConfig( + filter_enabled=False, + keywords=[], + dedup_enabled=True, + notify_mode="all", + mark_sent=True, + ) + + async def crawl(self) -> CrawlResult: + start_time = datetime.now() + url = self.BASE_URL + self.ANNOUNCEMENT_PATH + + async with httpx.AsyncClient(timeout=settings.crawler_timeout) as client: + await self._delay() + try: + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Accept": "text/html,application/xhtml+xml", + "Accept-Language": "zh-CN,zh;q=0.9", + "Referer": self.BASE_URL, + } + response = await client.get(url, headers=headers) + if response.status_code != 200: + return CrawlResult( + source_code=self.source_code, + source_name=self.source_name, + error_message=f"HTTP {response.status_code}", + crawled_at=start_time, + ) + html = response.text + except Exception as e: + return CrawlResult( + source_code=self.source_code, + source_name=self.source_name, + error_message=str(e), + crawled_at=start_time, + ) + + announcements = parse_dahuagov_html(html, start_time) + duration = (datetime.now() - start_time).total_seconds() + + return CrawlResult( + source_code=self.source_code, + source_name=self.source_name, + total_count=len(announcements), + new_count=len(announcements), + announcements=announcements, + crawled_at=start_time, + duration=duration, + ) + + async def _delay(self): + delay = random.uniform(1.0, 3.0) + await asyncio.sleep(delay) +``` + +- [ ] **Step 4: 运行测试验证通过** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +python -m pytest tests/test_crawler/test_dahuagov_spider.py -v +# Expected: 4 passed +``` + +- [ ] **Step 5: 运行全部测试** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +python -m pytest tests/ -v +# Expected: 19 passed +``` + +- [ ] **Step 6: 提交** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +git add app/crawler/dahuagov_spider.py tests/test_crawler/test_dahuagov_spider.py +git commit -m "feat: 添加 Dahuagov Spider(大化县政府网爬虫)+ 测试" +``` + +--- + +### Task 7: CrawlService(爬取编排器) + +**Files:** +- Create: `app/services/__init__.py` +- Create: `app/services/crawl_service.py` +- Test: `tests/test_services/__init__.py` +- Test: `tests/test_services/test_crawl_service.py` + +- [ ] **Step 1: 创建测试 tests/test_services/test_crawl_service.py** + +```python +import pytest +from datetime import datetime +from unittest.mock import AsyncMock, patch +from app.crawler.base import BaseSpider, CrawlResult, PipelineConfig +from app.services.crawl_service import CrawlService + + +class MockGXGPSpider(BaseSpider): + name = "mock_gxgp" + source_code = "mock_gxgp" + source_name = "Mock GXGP" + + async def crawl(self, **kwargs): + return CrawlResult( + source_code=self.source_code, + source_name=self.source_name, + total_count=10, + new_count=5, + announcements=[ + { + "title": f"公告{i}", + "publish_date": datetime(2026, 5, 9), + "purchase_name": "单位", + "content_url": f"https://x.com/{i}", + "source_code": "ZcyAnnouncement1", + "source_name": "采购公告", + "announcement_type": "purchase", + "crawl_mode": "auto", + "is_new": True, + "is_today": True, + "content_hash": f"hash{i}", + } + for i in range(10) + ], + ) + + def get_pipeline_config(self): + return PipelineConfig( + filter_enabled=True, + keywords=["大化"], + notify_mode="filtered", + ) + + +class MockDahuagovSpider(BaseSpider): + name = "mock_dahuagov" + source_code = "mock_dahuagov" + source_name = "Mock Dahuagov" + + async def crawl(self, **kwargs): + return CrawlResult( + source_code=self.source_code, + source_name=self.source_name, + total_count=3, + new_count=3, + announcements=[ + { + "title": f"大化公告{j}", + "publish_date": datetime(2026, 5, 9), + "purchase_name": "大化瑶族自治县", + "content_url": f"https://dh.com/{j}", + "source_code": "dahuagov", + "source_name": "大化县政府网采购公告", + "announcement_type": "purchase", + "crawl_mode": "auto", + "is_new": True, + "is_today": True, + "content_hash": f"dh_hash{j}", + } + for j in range(3) + ], + ) + + def get_pipeline_config(self): + return PipelineConfig( + filter_enabled=False, + notify_mode="all", + mark_sent=True, + ) + + +@pytest.mark.asyncio +async def test_crawl_service_registers_spiders(): + service = CrawlService() + service.register(MockGXGPSpider()) + service.register(MockDahuagovSpider()) + assert len(service.spiders) == 2 + + +@pytest.mark.asyncio +async def test_crawl_service_run_all(): + service = CrawlService() + service.register(MockGXGPSpider()) + service.register(MockDahuagovSpider()) + results = await service.run_all() + assert len(results) == 2 + assert results[0].total_count == 10 + assert results[1].total_count == 3 + assert all(r.success for r in results) + + +@pytest.mark.asyncio +async def test_crawl_service_run_specific(): + service = CrawlService() + service.register(MockGXGPSpider()) + service.register(MockDahuagovSpider()) + results = await service.run_spider("mock_dahuagov") + assert len(results) == 1 + assert results[0].source_code == "mock_dahuagov" +``` + +- [ ] **Step 2: 运行测试确认失败** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +python -m pytest tests/test_services/test_crawl_service.py -v +# Expected: FAIL — ImportError +``` + +- [ ] **Step 3: 创建 app/services/__init__.py** (空文件) + +- [ ] **Step 4: 创建 app/services/crawl_service.py** + +```python +from typing import Dict, List +from app.crawler.base import BaseSpider, CrawlResult + + +class CrawlService: + def __init__(self): + self.spiders: Dict[str, BaseSpider] = {} + + def register(self, spider: BaseSpider): + self.spiders[spider.name] = spider + + async def run_all(self) -> List[CrawlResult]: + results = [] + for name, spider in self.spiders.items(): + result = await spider.crawl() + results.append(result) + return results + + async def run_spider(self, name: str, **kwargs) -> List[CrawlResult]: + spider = self.spiders.get(name) + if spider is None: + return [CrawlResult( + source_code=name, source_name=name, + error_message=f"Spider not found: {name}" + )] + result = await spider.crawl(**kwargs) + return [result] + + def get_spider_names(self) -> List[str]: + return list(self.spiders.keys()) + + def get_pipeline_config(self, name: str): + spider = self.spiders.get(name) + if spider: + return spider.get_pipeline_config() + return None +``` + +- [ ] **Step 5: 运行测试验证通过** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +python -m pytest tests/test_services/test_crawl_service.py -v +# Expected: 3 passed +``` + +- [ ] **Step 6: 提交** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +git add app/services/ tests/test_services/ +git commit -m "feat: 添加 CrawlService 爬取编排器 + 测试" +``` + +--- + +### Task 8: PostCrawlPipeline 统一管道 + +**Files:** +- Create: `app/services/pipeline.py` +- Create: `app/services/filter_service.py` +- Test: `tests/test_services/test_pipeline.py` +- Test: `tests/test_services/test_filter_service.py` + +- [ ] **Step 1: 创建过滤测试 tests/test_services/test_filter_service.py** + +```python +from datetime import datetime +from app.services.filter_service import filter_by_keywords, filter_by_date + + +def test_filter_by_keywords_match(): + announcements = [ + {"title": "大化县采购公告", "purchase_name": "大化县财政局", + "source_code": "test", "source_name": "test"}, + {"title": "南宁市采购公告", "purchase_name": "南宁市财政局", + "source_code": "test", "source_name": "test"}, + ] + result = filter_by_keywords(announcements, ["大化"]) + assert len(result) == 1 + assert result[0]["title"] == "大化县采购公告" + + +def test_filter_by_keywords_no_keywords(): + announcements = [ + {"title": "大化县采购公告", "purchase_name": "x", + "source_code": "test", "source_name": "test"}, + ] + result = filter_by_keywords(announcements, []) + assert len(result) == 1 + + +def test_filter_by_date_range(): + today = datetime(2026, 5, 9) + announcements = [ + {"title": "t1", "publish_date": datetime(2026, 5, 9), + "source_code": "test", "source_name": "test"}, + {"title": "t2", "publish_date": datetime(2026, 5, 1), + "source_code": "test", "source_name": "test"}, + {"title": "t3", "publish_date": datetime(2026, 4, 30), + "source_code": "test", "source_name": "test"}, + ] + result = filter_by_date(announcements, start_date=today, end_date=today) + assert len(result) == 1 + assert result[0]["title"] == "t1" +``` + +- [ ] **Step 2: 创建管道测试 tests/test_services/test_pipeline.py** + +```python +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from datetime import datetime +from app.services.pipeline import PostCrawlPipeline +from app.crawler.base import CrawlResult, PipelineConfig + + +@pytest.mark.asyncio +async def test_pipeline_filtered_mode(): + config = PipelineConfig( + filter_enabled=True, + keywords=["大化"], + dedup_enabled=True, + notify_mode="filtered", + mark_sent=False, + ) + result = CrawlResult( + source_code="test", source_name="test", + total_count=3, new_count=3, + announcements=[ + { + "title": "大化县公告", "publish_date": datetime(2026, 5, 9), + "purchase_name": "大化县", "content_url": "https://1.com", + "source_code": "test", "source_name": "测试", + "announcement_type": "purchase", "crawl_mode": "auto", + "is_new": True, "is_today": True, + "content_hash": "abc123", + }, + { + "title": "南宁市公告", "publish_date": datetime(2026, 5, 9), + "purchase_name": "南宁市", "content_url": "https://2.com", + "source_code": "test", "source_name": "测试", + "announcement_type": "purchase", "crawl_mode": "auto", + "is_new": True, "is_today": True, + "content_hash": "def456", + }, + ], + ) + + mock_db = AsyncMock() + mock_notify = AsyncMock() + + pipeline = PostCrawlPipeline(db_session=mock_db, notification_service=mock_notify) + + with patch.object(pipeline, "_save_to_db", AsyncMock(return_value=2)): + with patch.object(pipeline, "_send_notifications", AsyncMock(return_value=1)): + pipe_result = await pipeline.process( + result.announcements, config + ) + assert pipe_result.stored == 2 + assert pipe_result.filtered == 1 # 3 total, 2 pass keyword, 1 filtered + assert pipe_result.notified == 1 + + +@pytest.mark.asyncio +async def test_pipeline_all_mode(): + config = PipelineConfig( + filter_enabled=False, + keywords=[], + dedup_enabled=True, + notify_mode="all", + mark_sent=True, + ) + result = CrawlResult( + source_code="dahuagov", source_name="大化县政府网", + total_count=2, new_count=2, + announcements=[ + { + "title": f"公告{i}", "publish_date": datetime(2026, 5, 9), + "purchase_name": "大化县", "content_url": f"https://x.com/{i}", + "source_code": "dahuagov", "source_name": "大化县政府网采购公告", + "announcement_type": "purchase", "crawl_mode": "auto", + "is_new": True, "is_today": True, + "content_hash": f"hash{i}", + } + for i in range(2) + ], + ) + + mock_db = AsyncMock() + mock_notify = AsyncMock() + pipeline = PostCrawlPipeline(db_session=mock_db, notification_service=mock_notify) + + with patch.object(pipeline, "_save_to_db", AsyncMock(return_value=2)): + with patch.object(pipeline, "_send_notifications", AsyncMock(return_value=2)): + with patch.object(pipeline, "_mark_sent", AsyncMock(return_value=2)): + pipe_result = await pipeline.process( + result.announcements, config + ) + assert pipe_result.stored == 2 + assert pipe_result.filtered == 0 + assert pipe_result.notified == 2 + pipeline._mark_sent.assert_awaited_once() +``` + +- [ ] **Step 3: 运行测试确认失败** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +python -m pytest tests/test_services/test_filter_service.py tests/test_services/test_pipeline.py -v +# Expected: FAIL — ModuleNotFoundError +``` + +- [ ] **Step 4: 创建 app/services/filter_service.py** + +```python +from datetime import date +from typing import Any, Dict, List, Optional + + +def filter_by_keywords(announcements: List[Dict[str, Any]], + keywords: List[str]) -> List[Dict[str, Any]]: + if not keywords: + return announcements + + filtered = [] + for ann in announcements: + search_text = f"{ann.get('title', '')} {ann.get('purchase_name', '')}" + if any(kw in search_text for kw in keywords): + ann["keyword_matched"] = True + filtered.append(ann) + else: + ann["keyword_matched"] = False + filtered.append(ann) # Still include, just mark not matched + + return filtered + + +def filter_by_date(announcements: List[Dict[str, Any]], + start_date: Optional[date] = None, + end_date: Optional[date] = None) -> List[Dict[str, Any]]: + if not start_date and not end_date: + return announcements + + filtered = [] + for ann in announcements: + pub_date = ann.get("publish_date") + if not pub_date: + continue + if isinstance(pub_date, date): + pub_date = pub_date + else: + pub_date = pub_date.date() if hasattr(pub_date, "date") else pub_date + + if start_date and pub_date < start_date: + continue + if end_date and pub_date > end_date: + continue + filtered.append(ann) + + return filtered + + +def dedup_by_hash(announcements: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + seen = set() + result = [] + for ann in announcements: + h = ann.get("content_hash") + if h and h not in seen: + seen.add(h) + result.append(ann) + return result +``` + +- [ ] **Step 5: 创建 app/services/pipeline.py** + +```python +from typing import Any, Dict, List +from app.crawler.base import PipelineConfig, PipelineResult +from app.services.filter_service import filter_by_keywords, dedup_by_hash + + +class PostCrawlPipeline: + def __init__(self, db_session, notification_service): + self.db = db_session + self.notify = notification_service + + async def process(self, announcements: List[Dict[str, Any]], + config: PipelineConfig) -> PipelineResult: + result = PipelineResult() + + if not announcements: + return result + + # 1. 去重 + if config.dedup_enabled: + announcements = dedup_by_hash(announcements) + + # 2. 存储到数据库 + stored = await self._save_to_db(announcements) + result.stored = stored + + to_notify = announcements + + # 3. 筛选 + if config.filter_enabled and config.keywords: + before = len(to_notify) + to_notify = [a for a in to_notify + if self._match_keywords(a, config.keywords)] + result.filtered = before - len(to_notify) + + # 4. 推送 + if config.notify_mode == "all": + result.notified = await self._send_notifications(to_notify) + elif config.notify_mode == "filtered": + if config.filter_enabled and config.keywords: + result.notified = await self._send_notifications(to_notify) + elif not config.filter_enabled: + result.notified = await self._send_notifications(to_notify) + + # 5. 标记已发送 + if config.mark_sent and result.notified > 0: + await self._mark_sent(to_notify) + + return result + + async def _save_to_db(self, announcements: List[Dict[str, Any]]) -> int: + from sqlalchemy.dialects.postgresql import insert + from app.models.announcement import Announcement + + if not announcements: + return 0 + + values = [{ + "title": a["title"], + "publish_date": a["publish_date"], + "purchase_name": a.get("purchase_name", ""), + "content_url": a.get("content_url", ""), + "source_code": a["source_code"], + "source_name": a["source_name"], + "announcement_type": a.get("announcement_type", "purchase"), + "content_hash": a["content_hash"], + "crawl_mode": a.get("crawl_mode", "auto"), + "is_new": a.get("is_new", True), + "is_sent": False, + "keyword_matched": a.get("keyword_matched", False), + } for a in announcements] + + stmt = insert(Announcement).values(values) + stmt = stmt.on_conflict_do_nothing(index_elements=["content_hash"]) + + result_proxy = await self.db.execute(stmt) + await self.db.commit() + return result_proxy.rowcount or len(values) + + async def _send_notifications(self, announcements: List[Dict[str, Any]]) -> int: + return await self.notify.send(announcements) + + async def _mark_sent(self, announcements: List[Dict[str, Any]]) -> int: + from app.models.announcement import Announcement + from sqlalchemy import update + + hashes = [a["content_hash"] for a in announcements if a.get("content_hash")] + if not hashes: + return 0 + + stmt = ( + update(Announcement) + .where(Announcement.content_hash.in_(hashes)) + .values(is_sent=True) + ) + result = await self.db.execute(stmt) + await self.db.commit() + return result.rowcount + + @staticmethod + def _match_keywords(announcement: Dict[str, Any], keywords: List[str]) -> bool: + text = f"{announcement.get('title', '')} {announcement.get('purchase_name', '')}" + return any(kw in text for kw in keywords) +``` + +- [ ] **Step 6: 运行测试验证通过** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +python -m pytest tests/test_services/ -v +# Expected: all passed +``` + +- [ ] **Step 7: 运行全部测试** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +python -m pytest tests/ -v +# Expected: all passed +``` + +- [ ] **Step 8: 提交** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +git add app/services/pipeline.py app/services/filter_service.py tests/test_services/ +git commit -m "feat: 添加 PostCrawlPipeline 统一管道 + 筛选服务 + 测试" +``` + +--- + +### Task 9: 企业微信模块 + +**Files:** +- Create: `app/wechat/__init__.py` +- Create: `app/wechat/crypto.py` — 复制现有 WXBizMsgCrypt +- Create: `app/wechat/client.py` — 企业微信 API 客户端 +- Create: `app/wechat/handler.py` — 消息处理器 + +- [ ] **Step 1: 创建 app/wechat/__init__.py** (空文件) + +- [ ] **Step 2: 创建 app/wechat/crypto.py** + +从现有 `gx_gp_monitor/wechat/WXBizMsgCrypt.py` 和 `gx_gp_monitor/wechat/ierror.py` 复制并整合: + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +cp gx_gp_monitor/wechat/WXBizMsgCrypt.py app/wechat/crypto.py +cp gx_gp_monitor/wechat/ierror.py app/wechat/ierror.py +``` + +- [ ] **Step 3: 创建 app/wechat/client.py** + +```python +import time +import httpx +from typing import Optional +from app.config import settings + + +class WeChatClient: + def __init__(self): + self._access_token: Optional[str] = None + self._token_expires_at: float = 0 + + async def _get_access_token(self) -> Optional[str]: + now = time.time() + if self._access_token and now < self._token_expires_at: + return self._access_token + + url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken" + params = { + "corpid": settings.wechat_corp_id, + "corpsecret": settings.wechat_secret, + } + async with httpx.AsyncClient(timeout=30) as client: + response = await client.get(url, params=params) + data = response.json() + if data.get("errcode") == 0: + self._access_token = data["access_token"] + self._token_expires_at = now + data.get("expires_in", 7200) - 300 + return self._access_token + return None + + async def send_text(self, content: str, to_user: str = "@all") -> bool: + return await self._send_message("text", {"content": content}, to_user) + + async def send_markdown(self, content: str, to_user: str = "@all") -> bool: + return await self._send_message("markdown", {"content": content}, to_user) + + async def send_textcard(self, title: str, description: str, url: str, + to_user: str = "@all", btn_txt: str = "查看详情") -> bool: + return await self._send_message("textcard", { + "title": title, + "description": description, + "url": url, + "btntxt": btn_txt, + }, to_user) + + async def _send_message(self, msgtype: str, msg_data: dict, + to_user: str = "@all") -> bool: + token = await self._get_access_token() + if not token: + return False + + url = "https://qyapi.weixin.qq.com/cgi-bin/message/send" + params = {"access_token": token} + body = { + "touser": to_user, + "msgtype": msgtype, + "agentid": int(settings.wechat_agent_id), + msgtype: msg_data, + } + + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post(url, params=params, json=body) + data = response.json() + return data.get("errcode") == 0 +``` + +- [ ] **Step 4: 创建 app/wechat/handler.py** + +```python +import xml.etree.cElementTree as ET +from typing import Optional +from app.wechat.crypto import WXBizMsgCrypt +from app.config import settings + + +class WeChatMessageHandler: + def __init__(self): + self.wxcpt = WXBizMsgCrypt( + sToken=settings.wechat_token, + sEncodingAESKey=settings.wechat_encoding_aes_key, + sReceiveId=settings.wechat_corp_id, + ) + + def verify_url(self, msg_signature: str, timestamp: str, + nonce: str, echostr: str) -> Optional[str]: + ret, sEchoStr = self.wxcpt.VerifyURL(msg_signature, timestamp, nonce, echostr) + if ret == 0: + return sEchoStr.decode("utf-8") if isinstance(sEchoStr, bytes) else sEchoStr + return None + + def decrypt_message(self, post_data: str, msg_signature: str, + timestamp: str, nonce: str) -> Optional[ET.Element]: + ret, xml_content = self.wxcpt.DecryptMsg( + post_data, msg_signature, timestamp, nonce + ) + if ret != 0: + return None + return ET.fromstring(xml_content) + + def encrypt_response(self, response_xml: str, nonce: str, + timestamp: str) -> Optional[str]: + ret, encrypted = self.wxcpt.EncryptMsg(response_xml, nonce, timestamp) + if ret == 0: + return encrypted + return None + + def handle_event(self, event: str, event_key: Optional[str], + from_user: str) -> Optional[str]: + return None + + def handle_text(self, content: str, from_user: str) -> Optional[str]: + return None +``` + +- [ ] **Step 5: 提交** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +git add app/wechat/ +git commit -m "feat: 迁移企业微信模块(crypto + client + handler)" +``` + +--- + +### Task 10: Notification Service + +**Files:** +- Create: `app/services/notification_service.py` +- Test: `tests/test_services/test_notification_service.py` + +- [ ] **Step 1: 创建测试 tests/test_services/test_notification_service.py** + +```python +import pytest +from unittest.mock import AsyncMock, patch +from app.services.notification_service import NotificationService + + +@pytest.mark.asyncio +async def test_notification_send(): + svc = NotificationService() + announcements = [ + { + "title": "测试公告", + "publish_date": None, + "purchase_name": "测试单位", + "content_url": "https://x.com/1", + "source_code": "test", + "source_name": "测试来源", + "announcement_type": "purchase", + } + ] + + with patch.object(svc.client, "send_textcard", AsyncMock(return_value=True)): + count = await svc.send(announcements) + assert count == 1 + + +@pytest.mark.asyncio +async def test_notification_send_empty(): + svc = NotificationService() + count = await svc.send([]) + assert count == 0 + + +@pytest.mark.asyncio +async def test_notification_send_disabled(): + svc = NotificationService() + announcements = [{"title": "test"}] + + with patch.object(svc.client, "send_textcard", AsyncMock(return_value=False)): + count = await svc.send(announcements) + assert count == 0 +``` + +- [ ] **Step 2: 运行测试确认失败** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +python -m pytest tests/test_services/test_notification_service.py -v +# Expected: FAIL +``` + +- [ ] **Step 3: 创建 app/services/notification_service.py** + +```python +from datetime import datetime +from typing import Any, Dict, List +from app.config import settings +from app.wechat.client import WeChatClient + + +class NotificationService: + def __init__(self): + self.client = WeChatClient() + + async def send(self, announcements: List[Dict[str, Any]]) -> int: + if not settings.wechat_enabled: + return 0 + if not announcements: + return 0 + + sent = 0 + for ann in announcements: + try: + title = ann.get("title", "") + if len(title) > 128: + title = title[:125] + "..." + + purchase_name = ann.get("purchase_name", "") + if len(purchase_name) > 25: + purchase_name = purchase_name[:22] + "..." + + pub_date = ann.get("publish_date") + time_str = pub_date.strftime("%Y-%m-%d %H:%M") if pub_date else "时间未知" + + source_name = ann.get("source_name", "") + + description = ( + f'
' + f'{source_name} | {purchase_name} | {time_str}' + f'
' + ) + + url = ann.get("content_url", "") + + if await self.client.send_textcard(title, description, url): + sent += 1 + except Exception: + continue + + return sent +``` + +- [ ] **Step 4: 运行测试验证通过** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +python -m pytest tests/test_services/test_notification_service.py -v +# Expected: 3 passed +``` + +- [ ] **Step 5: 提交** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +git add app/services/notification_service.py tests/test_services/test_notification_service.py +git commit -m "feat: 添加 NotificationService 通知服务 + 测试" +``` + +--- + +### Task 11: API 路由 + +**Files:** +- Create: `app/api/__init__.py` +- Create: `app/api/deps.py` +- Create: `app/api/router.py` +- Create: `app/api/announcements.py` +- Create: `app/api/crawl.py` +- Create: `app/api/wechat.py` +- Modify: `app/main.py` — 注册路由 +- Test: `tests/test_api/__init__.py` +- Test: `tests/test_api/test_health.py` +- Test: `tests/test_api/test_announcements.py` +- Test: `tests/test_api/test_crawl.py` + +- [ ] **Step 1: 创建 app/api/__init__.py** (空文件) + +- [ ] **Step 2: 创建 app/api/deps.py** + +```python +from sqlalchemy.ext.asyncio import AsyncSession +from app.main import async_session +from app.services.crawl_service import CrawlService +from app.crawler.gxgp_spider import GXGPSpider +from app.crawler.dahuagov_spider import DahuagovSpider + + +async def get_db() -> AsyncSession: + async with async_session() as session: + yield session + + +_crawl_service: CrawlService | None = None + + +def get_crawl_service() -> CrawlService: + global _crawl_service + if _crawl_service is None: + _crawl_service = CrawlService() + _crawl_service.register(GXGPSpider()) + _crawl_service.register(DahuagovSpider()) + return _crawl_service +``` + +- [ ] **Step 3: 创建 app/api/router.py** + +```python +from fastapi import APIRouter +from app.api import announcements, crawl, wechat + +api_router = APIRouter(prefix="/api/v1") +api_router.include_router(announcements.router, tags=["announcements"]) +api_router.include_router(crawl.router, tags=["crawl"]) +api_router.include_router(wechat.router, tags=["wechat"]) +``` + +- [ ] **Step 4: 创建 app/api/announcements.py** + +```python +from datetime import datetime +from typing import Optional +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, text +from app.api.deps import get_db +from app.models.announcement import Announcement +from app.models.schemas import AnnouncementResponse, AnnouncementListResponse + +router = APIRouter() + + +@router.get("/announcements", response_model=AnnouncementListResponse) +async def list_announcements( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + source_code: Optional[str] = None, + keyword: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + crawl_mode: Optional[str] = None, + db: AsyncSession = Depends(get_db), +): + conditions = [] + if source_code: + conditions.append(Announcement.source_code == source_code) + if crawl_mode: + conditions.append(Announcement.crawl_mode == crawl_mode) + if start_date: + conditions.append(Announcement.publish_date >= start_date) + if end_date: + conditions.append(Announcement.publish_date <= end_date) + if keyword: + conditions.append( + Announcement.title.ilike(f"%{keyword}%") + ) + + base_query = select(Announcement) + if conditions: + base_query = base_query.where(*conditions) + + count_query = select(func.count()).select_from(base_query.subquery()) + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + items_query = base_query.order_by(Announcement.publish_date.desc()) \ + .offset((page - 1) * page_size).limit(page_size) + items_result = await db.execute(items_query) + items = items_result.scalars().all() + + return AnnouncementListResponse( + total=total, + page=page, + page_size=page_size, + items=[AnnouncementResponse.model_validate(item) for item in items], + ) + + +@router.get("/announcements/{announcement_id}", response_model=AnnouncementResponse) +async def get_announcement(announcement_id: int, db: AsyncSession = Depends(get_db)): + result = await db.execute( + select(Announcement).where(Announcement.id == announcement_id) + ) + item = result.scalar_one_or_none() + if item is None: + raise HTTPException(status_code=404, detail="公告不存在") + return AnnouncementResponse.model_validate(item) + + +@router.get("/announcements/today", response_model=AnnouncementListResponse) +async def get_today_announcements(db: AsyncSession = Depends(get_db)): + today = datetime.now().date() + result = await db.execute( + select(Announcement).where( + func.date(Announcement.publish_date) == today + ).order_by(Announcement.publish_date.desc()) + ) + items = result.scalars().all() + return AnnouncementListResponse( + total=len(items), page=1, page_size=len(items), + items=[AnnouncementResponse.model_validate(item) for item in items], + ) + + +@router.get("/announcements/stats") +async def get_stats(db: AsyncSession = Depends(get_db)): + total = await db.execute(select(func.count()).select_from(Announcement)) + today_count = await db.execute( + select(func.count()).where( + func.date(Announcement.publish_date) == func.current_date() + ).select_from(Announcement) + ) + new_count = await db.execute( + select(func.count()).where(Announcement.is_new == True) + .select_from(Announcement) + ) + unsent = await db.execute( + select(func.count()).where( + Announcement.is_sent == False, Announcement.is_new == True + ).select_from(Announcement) + ) + return { + "total": total.scalar() or 0, + "today": today_count.scalar() or 0, + "new": new_count.scalar() or 0, + "unsent": unsent.scalar() or 0, + } +``` + +- [ ] **Step 5: 创建 app/api/crawl.py** + +```python +from fastapi import APIRouter, Depends +from app.api.deps import get_crawl_service +from app.models.schemas import CrawlTriggerRequest + +router = APIRouter() + + +@router.post("/crawl/trigger") +async def trigger_crawl(request: CrawlTriggerRequest): + from app.services.pipeline import PostCrawlPipeline + from app.services.notification_service import NotificationService + from app.api.deps import get_db + import asyncio + + service = get_crawl_service() + names = service.get_spider_names() + + all_results = [] + for name in names: + results = await service.run_spider(name) + all_results.extend(results) + + return { + "spiders_run": names, + "total_announcements": sum(r.total_count for r in all_results), + "errors": [r.error_message for r in all_results if not r.success], + } + + +@router.get("/crawl/status") +async def crawl_status(): + service = get_crawl_service() + return { + "spiders": service.get_spider_names(), + "running": False, + } + + +@router.get("/crawl/sources") +async def crawl_sources(): + import json + from app.config import settings + sources = json.loads(settings.announcement_sources) + return { + "sources": [ + {"code": code, "name": info["name"], "type": info["type"]} + for code, info in sources.items() + ] + } +``` + +- [ ] **Step 6: 创建 app/api/wechat.py** + +```python +from fastapi import APIRouter, Request, Response +from app.wechat.handler import WeChatMessageHandler + +router = APIRouter() + +_handler: WeChatMessageHandler | None = None + + +def get_handler() -> WeChatMessageHandler: + global _handler + if _handler is None: + _handler = WeChatMessageHandler() + return _handler + + +@router.get("/wechat/callback") +async def wechat_verify(request: Request): + handler = get_handler() + params = request.query_params + echostr = handler.verify_url( + params.get("msg_signature", ""), + params.get("timestamp", ""), + params.get("nonce", ""), + params.get("echostr", ""), + ) + if echostr: + return Response(content=echostr, media_type="text/plain") + return Response(content="verification failed", status_code=403) + + +@router.post("/wechat/callback") +async def wechat_callback(request: Request): + handler = get_handler() + params = request.query_params + post_data = await request.body() + post_text = post_data.decode("utf-8") + + xml_tree = handler.decrypt_message( + post_text, + params.get("msg_signature", ""), + params.get("timestamp", ""), + params.get("nonce", ""), + ) + if xml_tree is None: + return Response(content="decrypt failed", status_code=403) + + msg_type = xml_tree.find("MsgType") + msg_type = msg_type.text if msg_type is not None else "unknown" + + if msg_type == "event": + event = xml_tree.find("Event") + event_key = xml_tree.find("EventKey") + from_user = xml_tree.find("FromUserName") + handler.handle_event( + event.text if event is not None else "", + event_key.text if event_key is not None else None, + from_user.text if from_user is not None else "", + ) + elif msg_type == "text": + content = xml_tree.find("Content") + from_user = xml_tree.find("FromUserName") + handler.handle_text( + content.text if content is not None else "", + from_user.text if from_user is not None else "", + ) + + return Response(content="success") +``` + +- [ ] **Step 7: 创建 app/api/scheduler.py** (scheduler endpoints 在 spec 4.3) + +```python +from fastapi import APIRouter +from app.scheduler.jobs import scheduler +from app.models.schemas import JobResponse + +router = APIRouter(prefix="/scheduler", tags=["scheduler"]) + + +@router.get("/jobs") +async def list_jobs(): + jobs = [] + for job in scheduler.get_jobs(): + jobs.append(JobResponse( + id=job.id, + name=job.name, + next_run_time=str(job.next_run_time) if job.next_run_time else None, + )) + return {"jobs": jobs} + + +@router.post("/pause/{job_id}") +async def pause_job(job_id: str): + scheduler.pause_job(job_id) + return {"status": "paused", "job_id": job_id} + + +@router.post("/resume/{job_id}") +async def resume_job(job_id: str): + scheduler.resume_job(job_id) + return {"status": "resumed", "job_id": job_id} +``` + +- [ ] **Step 8: 修改 app/api/router.py 注册 scheduler 路由** + +在 `app/api/router.py` 中添加: + +```python +from app.api import scheduler as scheduler_module +api_router.include_router(scheduler_module.router) +``` + +- [ ] **Step 9: 修改 app/main.py 注册路由** + +在 app/main.py 的 `app = FastAPI(...)` 之后添加: + +```python +from app.api.router import api_router + +app.include_router(api_router) +``` + +- [ ] **Step 10: 创建测试 tests/test_api/__init__.py** (空文件) + +- [ ] **Step 11: 创建测试 tests/test_api/test_health.py** + +```python +import pytest +from httpx import AsyncClient, ASGITransport +from app.main import app + + +@pytest.mark.asyncio +async def test_health(): + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "ok"} +``` + +- [ ] **Step 12: 运行 API 测试** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +python -m pytest tests/test_api/test_health.py -v +# Expected: 1 passed +``` + +- [ ] **Step 13: 提交** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +git add app/api/ app/main.py tests/test_api/ +git commit -m "feat: 添加 API 路由(公告/爬取/微信回调/健康检查)+ 测试" +``` + +--- + +### Task 12: APScheduler 定时任务 + +**Files:** +- Create: `app/scheduler/__init__.py` +- Create: `app/scheduler/jobs.py` +- Modify: `app/main.py` — lifespan 中启动调度器 + +- [ ] **Step 1: 创建 app/scheduler/__init__.py** (空文件) + +- [ ] **Step 2: 创建 app/scheduler/jobs.py** + +```python +import logging +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from app.config import settings +from app.api.deps import get_crawl_service + +logger = logging.getLogger(__name__) +scheduler = AsyncIOScheduler() + + +async def scheduled_crawl(): + """定时爬取任务:运行所有 Spider""" + logger.info("开始定时爬取任务") + service = get_crawl_service() + names = service.get_spider_names() + for name in names: + try: + results = await service.run_spider(name) + for r in results: + if not r.success: + logger.error(f"Spider {name} 失败: {r.error_message}") + else: + logger.info(f"Spider {name} 完成: {r.total_count} 条") + except Exception as e: + logger.error(f"Spider {name} 异常: {e}") + logger.info("定时爬取任务完成") + + +def start_scheduler(): + if not settings.scheduler_enabled: + return + scheduler.add_job( + scheduled_crawl, + "cron", + hour="8,14,18", + minute="0", + id="scheduled_crawl", + name="定时爬取", + timezone="Asia/Shanghai", + ) + scheduler.start() + logger.info("APScheduler 已启动 (8:00, 14:00, 18:00)") + + +def shutdown_scheduler(): + if scheduler.running: + scheduler.shutdown(wait=False) + logger.info("APScheduler 已停止") +``` + +- [ ] **Step 3: 修改 app/main.py 的 lifespan** + +```python +@asynccontextmanager +async def lifespan(app: FastAPI): + import logging + logging.basicConfig( + level=getattr(logging, settings.log_level), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + from app.scheduler.jobs import start_scheduler, shutdown_scheduler + start_scheduler() + yield + shutdown_scheduler() + await engine.dispose() +``` + +- [ ] **Step 4: 提交** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +git add app/scheduler/ app/main.py +git commit -m "feat: 添加 APScheduler 定时爬取任务" +``` + +--- + +### Task 13: Docker 化 + +**Files:** +- Create: `docker/Dockerfile` +- Create: `docker/docker-compose.yml` + +- [ ] **Step 1: 创建 docker/Dockerfile** + +```dockerfile +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpq-dev && \ + rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml . +RUN pip install --no-cache-dir -e ".[dev]" + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +``` + +- [ ] **Step 2: 创建 docker/docker-compose.yml** + +```yaml +services: + app: + build: + context: .. + dockerfile: docker/Dockerfile + ports: + - "8000:8000" + env_file: + - ../.env + volumes: + - ../logs:/app/logs + restart: unless-stopped +``` + +- [ ] **Step 3: 构建验证** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +docker build -f docker/Dockerfile -t gx-gp-notify . +# Expected: Successfully built +``` + +- [ ] **Step 4: 提交** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +git add docker/ +git commit -m "feat: 添加 Docker 部署配置" +``` + +--- + +### Task 14: 运行 ruff 代码检查 + +- [ ] **Step 1: 安装 ruff 并运行** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +pip install ruff +ruff check app/ tests/ +# Fix any issues +ruff check --fix app/ tests/ +``` + +- [ ] **Step 2: 提交修复** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +git add -A +git commit -m "chore: ruff 代码检查与修复" +``` + +--- + +### Task 15: 清理旧文件并最终验证 + +- [ ] **Step 1: 运行全部测试最终确认** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +python -m pytest tests/ -v +# Expected: all passed +``` + +- [ ] **Step 2: 验证服务启动** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +uvicorn app.main:app --host 0.0.0.0 --port 8000 & +sleep 2 +curl http://localhost:8000/health +curl http://localhost:8000/docs # 需要 debug=true +kill %1 +``` + +- [ ] **Step 3: 提交最终状态** + +```bash +cd /home/v6ole/PythonProject/GX-gp-notify +git add -A +git commit -m "chore: 清理旧文件,最终验证通过" +```