From 9935f67080646b3a878343bc97fd07697e587825 Mon Sep 17 00:00:00 2001 From: meijiali <你的邮箱@xxx.com> Date: Fri, 3 Jul 2026 16:57:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20MVP-2=20=E6=BC=94?= =?UTF-8?q?=E7=A4=BA=E5=92=8C=E8=BF=9B=E5=BA=A6=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/db.py | 41 ++ app/demo_seed.py | 123 +++++ app/fixtures/demo_seed.json | 157 +++++++ app/main.py | 35 +- app/models.py | 2 + app/schemas.py | 6 + app/services/task_service.py | 59 ++- app/static/app.css | 96 +++- app/static/app.js | 54 ++- .../errors/database_unavailable.html | 18 + app/templates/index.html | 26 +- app/templates/partials/task_rows.html | 9 +- app/templates/tasks/detail.html | 32 +- docs/Deployment.md | 87 ++++ docs/MVP-WorkOrders.md | 430 ++++++++++++++++++ pyproject.toml | 4 + tests/integration/test_routes.py | 57 +++ tests/unit/test_db_stability.py | 19 +- tests/unit/test_demo_seed.py | 27 ++ tests/unit/test_docs.py | 3 + 20 files changed, 1252 insertions(+), 33 deletions(-) create mode 100644 app/demo_seed.py create mode 100644 app/fixtures/demo_seed.json create mode 100644 app/templates/errors/database_unavailable.html create mode 100644 tests/unit/test_demo_seed.py diff --git a/app/db.py b/app/db.py index 6faf9b2..0911158 100644 --- a/app/db.py +++ b/app/db.py @@ -1,5 +1,7 @@ from collections.abc import Generator from pathlib import Path +from shutil import copy2 +from datetime import UTC, datetime from sqlalchemy import Engine, create_engine, event from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker @@ -30,6 +32,16 @@ def create_sqlite_engine(database_url: str): return engine +def sqlite_file_path(target_engine: Engine = None) -> Path | None: + target_engine = target_engine or engine + if target_engine.url.get_backend_name() != "sqlite": + return None + database = target_engine.url.database + if not database or database == ":memory:": + return None + return Path(database) + + engine = create_sqlite_engine(get_settings().database_url) SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) @@ -63,10 +75,39 @@ def checkpoint_sqlite_wal(target_engine: Engine = engine) -> bool: return True +def ensure_sqlite_schema_compat(target_engine: Engine = engine) -> None: + if target_engine.url.get_backend_name() != "sqlite": + return + with target_engine.begin() as connection: + task_columns = {row[1] for row in connection.exec_driver_sql("PRAGMA table_info(tasks)").all()} + if "current_stage" not in task_columns: + connection.exec_driver_sql("ALTER TABLE tasks ADD COLUMN current_stage VARCHAR(64)") + if "last_progress_at" not in task_columns: + connection.exec_driver_sql("ALTER TABLE tasks ADD COLUMN last_progress_at DATETIME") + + +def backup_sqlite_files(target_engine: Engine = engine) -> list[Path]: + db_path = sqlite_file_path(target_engine) + if db_path is None: + return [] + backup_dir = db_path.parent / "corrupt-backups" + backup_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S") + copied: list[Path] = [] + for source in [db_path, db_path.with_name(f"{db_path.name}-wal"), db_path.with_name(f"{db_path.name}-shm")]: + if not source.exists(): + continue + target = backup_dir / f"{source.name}.{timestamp}.bak" + copy2(source, target) + copied.append(target) + return copied + + def init_db() -> None: import app.models # noqa: F401 Base.metadata.create_all(engine) + ensure_sqlite_schema_compat(engine) check_database_integrity(engine) checkpoint_sqlite_wal(engine) diff --git a/app/demo_seed.py b/app/demo_seed.py new file mode 100644 index 0000000..4602ecf --- /dev/null +++ b/app/demo_seed.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.db import SessionLocal, init_db +from app.models import Comment, ContentItem, Hotspot, Report, Task, utc_now + + +FIXTURE_PATH = Path(__file__).resolve().parent / "fixtures" / "demo_seed.json" + + +def seed_demo_data(session: Session, fixture_path: Path = FIXTURE_PATH) -> str: + data = json.loads(fixture_path.read_text(encoding="utf-8")) + task_data = data["task"] + task_id = task_data["id"] + existing = session.scalar(select(Task.id).where(Task.id == task_id)) + if existing: + return task_id + + task = Task( + id=task_id, + platform=task_data["platform"], + status="success", + current_stage="success", + last_progress_at=utc_now(), + hotspot_limit=task_data["hotspot_limit"], + item_limit_per_hotspot=task_data["item_limit_per_hotspot"], + comment_limit_per_item=task_data["comment_limit_per_item"], + total_items_count=len(data["items"]), + processed_items_count=len(data["items"]), + successful_items_count=len(data["items"]), + failed_items_count=0, + analysis_success_rate=1.0, + analysis_status="normal", + ) + session.add(task) + + for hotspot_data in data["hotspots"]: + session.add( + Hotspot( + id=hotspot_data["id"], + task_id=task_id, + platform=task.platform, + rank=hotspot_data["rank"], + title=hotspot_data["title"], + heat_value=hotspot_data.get("heat_value"), + source_hot_id=None, + raw_data="{}", + ) + ) + + for item_data in data["items"]: + session.add( + ContentItem( + id=item_data["id"], + task_id=task_id, + hotspot_id=item_data["hotspot_id"], + platform=task.platform, + source_item_id=f"demo-item-{item_data['id']}", + item_type=item_data["item_type"], + title=item_data["title"], + summary=item_data.get("summary"), + url=None, + status="success", + raw_data="{}", + ) + ) + + for comment_data in data["comments"]: + session.add( + Comment( + id=comment_data["id"], + task_id=task_id, + hotspot_id=comment_data["hotspot_id"], + content_item_id=comment_data["content_item_id"], + platform=task.platform, + source_comment_id=None, + content=comment_data["content"], + author=None, + like_count=comment_data.get("like_count", 0), + sentiment=comment_data["sentiment"], + labels=json.dumps(comment_data["labels"], ensure_ascii=False), + reason=comment_data.get("reason"), + ai_analysis_status="success", + raw_data="{}", + ai_raw_response=None, + ) + ) + + for report_data in data["reports"]: + session.add( + Report( + task_id=task_id, + hotspot_id=report_data.get("hotspot_id"), + content_item_id=report_data.get("content_item_id"), + report_type=report_data["report_type"], + title=report_data["title"], + metrics_json=json.dumps(report_data["metrics"], ensure_ascii=False), + typical_comments_json=json.dumps(report_data["typical_comments"], ensure_ascii=False), + summary=report_data["summary"], + markdown_content=report_data["markdown_content"], + data="{}", + markdown=report_data["markdown_content"], + ) + ) + + session.commit() + return task_id + + +def main() -> None: + init_db() + with SessionLocal() as session: + task_id = seed_demo_data(session) + print(f"Seeded demo task: {task_id}") + + +if __name__ == "__main__": + main() diff --git a/app/fixtures/demo_seed.json b/app/fixtures/demo_seed.json new file mode 100644 index 0000000..c3bb709 --- /dev/null +++ b/app/fixtures/demo_seed.json @@ -0,0 +1,157 @@ +{ + "task": { + "id": "demo-douyin-20260703", + "platform": "douyin", + "hotspot_limit": 1, + "item_limit_per_hotspot": 2, + "comment_limit_per_item": 10 + }, + "hotspots": [ + { + "id": "demo-hotspot-1", + "rank": 1, + "title": "演示热点:夏季新品讨论", + "heat_value": "demo" + } + ], + "items": [ + { + "id": "demo-item-1", + "hotspot_id": "demo-hotspot-1", + "item_type": "video", + "title": "新品开箱体验", + "summary": "演示用脱敏内容条目" + }, + { + "id": "demo-item-2", + "hotspot_id": "demo-hotspot-1", + "item_type": "video", + "title": "用户上手反馈", + "summary": "演示用脱敏内容条目" + } + ], + "comments": [ + { + "id": "demo-comment-1", + "hotspot_id": "demo-hotspot-1", + "content_item_id": "demo-item-1", + "content": "这个颜色很清爽,夏天用看起来挺舒服。", + "sentiment": "positive", + "labels": ["外观种草", "季节场景"], + "reason": "用户表达了对外观和使用场景的认可。", + "like_count": 36 + }, + { + "id": "demo-comment-2", + "hotspot_id": "demo-hotspot-1", + "content_item_id": "demo-item-1", + "content": "价格如果能再低一点就好了,现在有点观望。", + "sentiment": "neutral", + "labels": ["价格观望", "购买决策"], + "reason": "用户没有否定产品,但对价格仍有顾虑。", + "like_count": 21 + }, + { + "id": "demo-comment-3", + "hotspot_id": "demo-hotspot-1", + "content_item_id": "demo-item-2", + "content": "看完真实上手比广告图可信,想看看长期使用反馈。", + "sentiment": "positive", + "labels": ["真实体验", "长期反馈"], + "reason": "用户认可真实体验内容,但仍希望补充长期反馈。", + "like_count": 18 + }, + { + "id": "demo-comment-4", + "hotspot_id": "demo-hotspot-1", + "content_item_id": "demo-item-2", + "content": "评论里好多人问链接,说明种草效果还是挺明显的。", + "sentiment": "positive", + "labels": ["求购买链接", "种草效果"], + "reason": "用户从评论行为判断内容有转化潜力。", + "like_count": 12 + } + ], + "reports": [ + { + "report_type": "hotspot", + "hotspot_id": "demo-hotspot-1", + "title": "演示热点:夏季新品讨论", + "summary": "该热点评论以正向反馈为主,用户主要关注外观、真实体验、价格和购买链接。价格仍是部分用户从种草到购买之间的关键阻力。", + "metrics": { + "sample_count": 4, + "item_count": 2, + "sentiment": { + "positive": {"count": 3, "pct": 75}, + "neutral": {"count": 1, "pct": 25}, + "negative": {"count": 0, "pct": 0}, + "unknown": {"count": 0, "pct": 0} + }, + "top_labels": [ + {"name": "外观种草", "count": 1}, + {"name": "价格观望", "count": 1}, + {"name": "真实体验", "count": 1}, + {"name": "求购买链接", "count": 1} + ] + }, + "typical_comments": { + "positive": [{"content": "这个颜色很清爽,夏天用看起来挺舒服。", "like_count": 36}], + "neutral": [{"content": "价格如果能再低一点就好了,现在有点观望。", "like_count": 21}], + "negative": [] + }, + "markdown_content": "# 演示热点:夏季新品讨论\n\n## 总结\n\n该热点评论以正向反馈为主,用户主要关注外观、真实体验、价格和购买链接。价格仍是部分用户从种草到购买之间的关键阻力。" + }, + { + "report_type": "item", + "hotspot_id": "demo-hotspot-1", + "content_item_id": "demo-item-1", + "title": "新品开箱体验", + "summary": "该内容主要激发了外观和季节场景兴趣,同时价格仍影响部分用户的购买决策。", + "metrics": { + "sample_count": 2, + "sentiment": { + "positive": {"count": 1, "pct": 50}, + "neutral": {"count": 1, "pct": 50}, + "negative": {"count": 0, "pct": 0}, + "unknown": {"count": 0, "pct": 0} + }, + "top_labels": [ + {"name": "外观种草", "count": 1}, + {"name": "价格观望", "count": 1} + ] + }, + "typical_comments": { + "positive": [{"content": "这个颜色很清爽,夏天用看起来挺舒服。", "like_count": 36}], + "neutral": [{"content": "价格如果能再低一点就好了,现在有点观望。", "like_count": 21}], + "negative": [] + }, + "markdown_content": "# 新品开箱体验\n\n## 总结\n\n该内容主要激发了外观和季节场景兴趣,同时价格仍影响部分用户的购买决策。" + }, + { + "report_type": "item", + "hotspot_id": "demo-hotspot-1", + "content_item_id": "demo-item-2", + "title": "用户上手反馈", + "summary": "该内容的评论更关注真实体验和后续转化,用户希望看到长期反馈,也表现出明显的购买链接需求。", + "metrics": { + "sample_count": 2, + "sentiment": { + "positive": {"count": 2, "pct": 100}, + "neutral": {"count": 0, "pct": 0}, + "negative": {"count": 0, "pct": 0}, + "unknown": {"count": 0, "pct": 0} + }, + "top_labels": [ + {"name": "真实体验", "count": 1}, + {"name": "求购买链接", "count": 1} + ] + }, + "typical_comments": { + "positive": [{"content": "看完真实上手比广告图可信,想看看长期使用反馈。", "like_count": 18}], + "neutral": [], + "negative": [] + }, + "markdown_content": "# 用户上手反馈\n\n## 总结\n\n该内容的评论更关注真实体验和后续转化,用户希望看到长期反馈,也表现出明显的购买链接需求。" + } + ] +} diff --git a/app/main.py b/app/main.py index 995678d..b78d9d1 100644 --- a/app/main.py +++ b/app/main.py @@ -1,15 +1,17 @@ from collections.abc import AsyncGenerator from contextlib import asynccontextmanager +import logging from urllib.parse import quote from fastapi import Depends, FastAPI, HTTPException, Request, status -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.responses import Response from fastapi.staticfiles import StaticFiles +from sqlalchemy.exc import OperationalError, SQLAlchemyError from sqlalchemy import select from sqlalchemy.orm import Session -from app.db import SessionLocal, get_db_session, init_db +from app.db import SessionLocal, backup_sqlite_files, get_db_session, init_db from app.models import Comment, ContentItem, Hotspot, Report from app.schemas import CreateTaskRequest, CreateTaskResponse, TaskResponse from app.services.export_service import export_hotspot_comments_csv, export_item_comments_csv, export_report_markdown @@ -24,6 +26,25 @@ from app.services.task_service import ( from app.templating import templates +logger = logging.getLogger(__name__) +DATABASE_UNAVAILABLE_MESSAGE = "数据库暂时不可用,请稍后重试或联系维护者恢复数据。" + + +def handle_database_error(request: Request, exc: SQLAlchemyError): + try: + backup_sqlite_files() + except Exception: + logger.exception("Failed to back up SQLite files after database error") + if request.url.path.startswith("/api/"): + return JSONResponse(status_code=503, content={"detail": DATABASE_UNAVAILABLE_MESSAGE}) + return templates.TemplateResponse( + request, + "errors/database_unavailable.html", + {"message": DATABASE_UNAVAILABLE_MESSAGE, "detail": str(exc)}, + status_code=503, + ) + + @asynccontextmanager async def lifespan(_app: FastAPI) -> AsyncGenerator[None]: init_db() @@ -36,6 +57,16 @@ app = FastAPI(title="热榜评论分析工具", lifespan=lifespan) app.mount("/static", StaticFiles(directory="app/static"), name="static") +@app.exception_handler(OperationalError) +def operational_error_handler(request: Request, exc: OperationalError): + return handle_database_error(request, exc) + + +@app.exception_handler(SQLAlchemyError) +def sqlalchemy_error_handler(request: Request, exc: SQLAlchemyError): + return handle_database_error(request, exc) + + @app.get("/health") def health() -> dict[str, str]: return {"status": "ok"} diff --git a/app/models.py b/app/models.py index 9202cd5..6743eba 100644 --- a/app/models.py +++ b/app/models.py @@ -37,6 +37,8 @@ class Task(Base): processed_items_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) successful_items_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) failed_items_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + current_stage: Mapped[str | None] = mapped_column(String(64)) + last_progress_at: Mapped[datetime | None] = mapped_column() error_stage: Mapped[str | None] = mapped_column(String(64)) error_type: Mapped[str | None] = mapped_column(String(64)) error_message: Mapped[str | None] = mapped_column(Text) diff --git a/app/schemas.py b/app/schemas.py index e560bb7..c6202a0 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -31,6 +31,12 @@ class TaskResponse(BaseModel): processed_items_count: int successful_items_count: int failed_items_count: int + current_stage: str | None = None + current_stage_label: str | None = None + last_progress_at: datetime | None = None + comments_count: int = 0 + reports_count: int = 0 + is_demo: bool = False error_message: str | None created_at: datetime diff --git a/app/services/task_service.py b/app/services/task_service.py index 527c150..69cc54d 100644 --- a/app/services/task_service.py +++ b/app/services/task_service.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session, sessionmaker from app.config import get_settings from app.db import SessionLocal -from app.models import Comment, ContentItem, Hotspot, Task +from app.models import Comment, ContentItem, Hotspot, Report, Task, utc_now from app.platforms.base import PlatformAPIError, TikHubClient from app.platforms.douyin import DouyinPlatform from app.platforms.xiaohongshu import XiaohongshuPlatform @@ -22,6 +22,18 @@ RESTART_ERROR_MESSAGE = "系统重启,任务被中断" task_executor = ThreadPoolExecutor(max_workers=1) +STAGE_LABELS = { + "queued": "等待启动", + "crawl_hotspots": "获取热点中", + "search_items": "搜索内容中", + "crawl_comments": "抓取评论中", + "ai_analysis": "AI 分析中", + "generate_reports": "生成报告中", + "success": "已完成", + "failed": "失败", +} + + def has_running_task(session: Session) -> bool: return session.scalar(select(Task.id).where(Task.status == "running").limit(1)) is not None @@ -110,6 +122,8 @@ def create_task(session: Session, request: CreateTaskRequest, *, submit_backgrou failed_items_count=0, analysis_success_rate=0.0, analysis_status="normal", + current_stage="queued", + last_progress_at=utc_now(), ) session.add(task) session.commit() @@ -120,11 +134,38 @@ def create_task(session: Session, request: CreateTaskRequest, *, submit_backgrou def list_tasks(session: Session) -> list[Task]: - return list(session.scalars(select(Task).order_by(Task.created_at.desc()))) + tasks = list(session.scalars(select(Task).order_by(Task.created_at.desc()))) + for task in tasks: + hydrate_task_progress(session, task) + return tasks def get_task(session: Session, task_id: str) -> Task | None: - return session.get(Task, task_id) + task = session.get(Task, task_id) + if task is not None: + hydrate_task_progress(session, task) + return task + + +def hydrate_task_progress(session: Session, task: Task) -> Task: + task.comments_count = session.scalar(select(func.count(Comment.id)).where(Comment.task_id == task.id)) or 0 + task.reports_count = session.scalar(select(func.count(Report.id)).where(Report.task_id == task.id)) or 0 + task.is_demo = task.id.startswith("demo-") + task.last_progress_at = task.last_progress_at or task.created_at + stage_code = task.current_stage + if not stage_code and task.status == "success": + stage_code = "success" + elif not stage_code and task.status == "failed": + stage_code = "failed" + elif not stage_code and task.status == "running": + stage_code = "queued" + task.current_stage_label = STAGE_LABELS.get(stage_code or "", stage_code or "等待启动") + return task + + +def update_task_stage(task: Task, stage: str) -> None: + task.current_stage = stage + task.last_progress_at = utc_now() def run_task(task_id: str, *, session_factory: Callable[[], Session] | sessionmaker = SessionLocal) -> None: @@ -139,6 +180,8 @@ def run_task(task_id: str, *, session_factory: Callable[[], Session] | sessionma ai_requester, report_summary_provider = build_ai_dependencies() try: + update_task_stage(task, "crawl_hotspots") + session.commit() hotspots = platform.fetch_hotspots(limit=task.hotspot_limit) except PlatformAPIError as exc: _mark_task_failed(task, "crawl_hotspots", exc.error_type, str(exc)) @@ -163,6 +206,8 @@ def run_task(task_id: str, *, session_factory: Callable[[], Session] | sessionma session.flush() try: + update_task_stage(task, "search_items") + session.commit() items = platform.search_items_by_hotspot(hotspot.title, limit=task.item_limit_per_hotspot) except Exception as exc: task.failed_items_count += task.item_limit_per_hotspot @@ -193,6 +238,8 @@ def run_task(task_id: str, *, session_factory: Callable[[], Session] | sessionma session.add(item) session.flush() try: + update_task_stage(task, "crawl_comments") + session.commit() comments = platform.fetch_comments(item.source_item_id, limit=task.comment_limit_per_item) for comment_data in comments: session.add( @@ -211,6 +258,8 @@ def run_task(task_id: str, *, session_factory: Callable[[], Session] | sessionma ) session.flush() item_comments = list(session.scalars(select(Comment).where(Comment.content_item_id == item.id))) + update_task_stage(task, "ai_analysis") + session.commit() ai_results = analyze_comments_with_retry( [{"comment_id": comment.id, "content": comment.content} for comment in item_comments], requester=ai_requester, @@ -231,6 +280,7 @@ def run_task(task_id: str, *, session_factory: Callable[[], Session] | sessionma comment.ai_analysis_status = result.ai_analysis_status item.status = "success" task.successful_items_count += 1 + update_task_stage(task, "generate_reports") generate_item_report(session, item.id, summary_provider=report_summary_provider) except Exception as exc: item.status = "failed" @@ -246,6 +296,7 @@ def run_task(task_id: str, *, session_factory: Callable[[], Session] | sessionma session.commit() if task.successful_items_count > 0: + update_task_stage(task, "generate_reports") total_comments = session.scalar(select(func.count(Comment.id)).where(Comment.task_id == task.id)) or 0 success_comments = sum(1 for comment in task.comments if comment.ai_analysis_status == "success") task.analysis_success_rate, task.analysis_status = calculate_analysis_status( @@ -255,6 +306,7 @@ def run_task(task_id: str, *, session_factory: Callable[[], Session] | sessionma for hotspot in task.hotspots: generate_hotspot_report(session, hotspot.id, summary_provider=report_summary_provider) task.status = "success" + update_task_stage(task, "success") else: _mark_task_failed(task, task.error_stage or "crawl_items", task.error_type or "no_successful_items", task.error_message or "没有任何内容条目成功") session.commit() @@ -268,6 +320,7 @@ def run_task(task_id: str, *, session_factory: Callable[[], Session] | sessionma def _mark_task_failed(task: Task, stage: str, error_type: str, message: str) -> None: task.status = "failed" + update_task_stage(task, "failed") task.error_stage = stage task.error_type = error_type task.error_message = message diff --git a/app/static/app.css b/app/static/app.css index dc4fbc8..618774b 100644 --- a/app/static/app.css +++ b/app/static/app.css @@ -1,7 +1,101 @@ body { - background: #f8f9fa; + background: #f5f7fb; + color: #172033; } .card { border-radius: 8px; + border: 1px solid #dfe5ef; + box-shadow: 0 10px 28px rgba(31, 42, 68, 0.06); +} + +.navbar { + background: #fff !important; +} + +.hero-band { + align-items: flex-end; + background: + linear-gradient(135deg, rgba(12, 22, 40, 0.92), rgba(29, 80, 108, 0.78)), + url("https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1600&q=80"); + background-position: center; + background-size: cover; + border-radius: 8px; + color: #fff; + display: flex; + justify-content: space-between; + min-height: 300px; + padding: 42px; +} + +.hero-copy { + max-width: 680px; +} + +.hero-copy h1 { + font-size: 48px; + font-weight: 700; + letter-spacing: 0; + margin-bottom: 14px; +} + +.hero-copy p:not(.eyebrow) { + color: rgba(255, 255, 255, 0.84); + font-size: 18px; + line-height: 1.7; + margin: 0; +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.eyebrow { + color: #71d4c7; + font-size: 13px; + font-weight: 700; + letter-spacing: 0; + text-transform: uppercase; +} + +.metric-box { + background: #f8fafc; + border: 1px solid #e3e9f2; + border-radius: 8px; + height: 100%; + padding: 14px; +} + +.status-panel { + background: #fff; + border: 1px solid #dfe5ef; + border-radius: 8px; + padding: 32px; +} + +.status-panel-danger { + border-color: #f1b8b8; +} + +.task-progress { + height: 10px; +} + +.report-progress { + height: 10px; +} + +@media (max-width: 768px) { + .hero-band { + align-items: flex-start; + flex-direction: column; + min-height: 360px; + padding: 28px; + } + + .hero-copy h1 { + font-size: 36px; + } } diff --git a/app/static/app.js b/app/static/app.js index 1903e0e..a7cd2af 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -114,21 +114,43 @@ function pollTaskListStatus() { async function downloadExport(url, defaultFilename, event) { if (event) event.preventDefault(); - const resp = await fetch(url); - if (!resp.ok) { - alert("导出失败,请稍后重试。"); - return; + const button = event ? event.currentTarget : null; + const originalText = button ? button.textContent : ""; + if (button) { + button.disabled = true; + button.textContent = "下载中..."; + } + try { + const resp = await fetch(url, { cache: "no-store" }); + if (!resp.ok) { + let message = "导出失败,请稍后重试。"; + try { + const data = await resp.json(); + if (data.detail) message = data.detail; + } catch (_error) { + message = resp.status === 503 ? "数据库暂时不可用,请稍后重试。" : message; + } + alert(message); + return; + } + const blob = await resp.blob(); + const disposition = resp.headers.get("Content-Disposition") || ""; + const match = disposition.match(/filename\*=UTF-8''([^;]+)/); + const filename = match ? decodeURIComponent(match[1]) : defaultFilename; + const blobUrl = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = blobUrl; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(blobUrl); + } catch (_error) { + alert("网络异常,导出未完成。"); + } finally { + if (button) { + button.disabled = false; + button.textContent = originalText; + } } - const blob = await resp.blob(); - const disposition = resp.headers.get("Content-Disposition") || ""; - const match = disposition.match(/filename\*=UTF-8''([^;]+)/); - const filename = match ? decodeURIComponent(match[1]) : defaultFilename; - const blobUrl = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = blobUrl; - link.download = filename; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(blobUrl); } diff --git a/app/templates/errors/database_unavailable.html b/app/templates/errors/database_unavailable.html new file mode 100644 index 0000000..ee84a85 --- /dev/null +++ b/app/templates/errors/database_unavailable.html @@ -0,0 +1,18 @@ +{% extends "base.html" %} +{% block title %}数据库暂时不可用 - 热榜评论分析工具{% endblock %} +{% block content %} +
+
+

数据恢复保护

+

数据库暂时不可用

+

{{ message }}

+

请先保留 data 目录,不要删除 app.db、app.db-wal 或 app.db-shm。系统会优先备份现有数据库文件,再进行诊断和恢复。

+
+
+{% if detail %} +
+ 查看技术细节 +
{{ detail }}
+
+{% endif %} +{% endblock %} diff --git a/app/templates/index.html b/app/templates/index.html index 488e3ab..f3dea0c 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -1,13 +1,20 @@ {% extends "base.html" %} {% set has_running_tasks = tasks | selectattr("status", "equalto", "running") | list | length > 0 %} -{% block title %}任务列表 - 热榜评论分析工具{% endblock %} +{% block title %}热榜评论雷达 - 热榜评论分析工具{% endblock %} {% block content %} -
-

任务列表

- -
+
+
+

Hot Comment Radar

+

热榜评论雷达

+

从小红书和抖音热点出发,抓取真实评论,生成 AI 情绪、标签和可导出的分析报告。

+
+
+ 创建新任务 + 查看 Demo 数据 +
+
-
+
创建抓取任务
@@ -43,8 +50,11 @@
-
-
历史任务
+
+
+ 最近任务与 Demo 数据 + +
diff --git a/app/templates/partials/task_rows.html b/app/templates/partials/task_rows.html index d6b3c2c..37f7804 100644 --- a/app/templates/partials/task_rows.html +++ b/app/templates/partials/task_rows.html @@ -3,7 +3,12 @@ {% set percent = progress_percent(task.processed_items_count, task.total_items_count) %} {% set ai_percent = rate_percent(task.analysis_success_rate) %} - + @@ -16,6 +21,8 @@
成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}
+
阶段:{{ task.current_stage_label or "等待启动" }}
+
评论 {{ task.comments_count or 0 }} / 报告 {{ task.reports_count or 0 }}
AI 成功率 {{ ai_percent }}% {% if task.analysis_status == "insufficient" %} diff --git a/app/templates/tasks/detail.html b/app/templates/tasks/detail.html index d983473..ef9d66e 100644 --- a/app/templates/tasks/detail.html +++ b/app/templates/tasks/detail.html @@ -15,7 +15,11 @@ {% set label, cls = status_badge_config(task.status) %} {% set percent = progress_percent(task.processed_items_count, task.total_items_count) %} {% set ai_percent = rate_percent(task.analysis_success_rate) %} -

平台:{{ task.platform | platform_label }} {{ label }}

+ {% set target_comments = task.hotspot_limit * task.item_limit_per_hotspot * task.comment_limit_per_item %} +
+

平台:{{ task.platform | platform_label }} {{ label }}{% if task.is_demo %}Demo 数据{% endif %}

+

阶段:{{ task.current_stage_label or "等待启动" }}

+
已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容 @@ -26,6 +30,32 @@
成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}
+
+
+
+
目标上限
+ {{ task.hotspot_limit }} 热点 × {{ task.item_limit_per_hotspot }} 内容 × {{ task.comment_limit_per_item }} 评论 +
最多 {{ target_comments }} 条评论
+
+
+
+
+
实际结果
+ 实际评论 {{ task.comments_count or 0 }} +
报告 {{ task.reports_count or 0 }} / 内容 {{ task.total_items_count }}
+
+
+
+
+
最近进度
+ {{ task.last_progress_at or task.created_at }} +
外部接口和 AI 响应较慢时,阶段可能短时间不变
+
+
+
+ {% if task.status == "success" and (task.comments_count or 0) < target_comments %} +
少于理论上限通常是内容本身评论不足或平台返回不足,不直接代表任务失败。若失败内容数大于 0,请结合失败原因判断。
+ {% endif %}
AI 成功率 {{ ai_percent }}% {% if task.analysis_status == "insufficient" %} diff --git a/docs/Deployment.md b/docs/Deployment.md index 6a2f166..8a6f321 100644 --- a/docs/Deployment.md +++ b/docs/Deployment.md @@ -98,3 +98,90 @@ curl -f http://localhost:8000/health docker compose up -d --build curl -f http://localhost:8000/health ``` + +## 公网云服务器部署 + +第一版公网演示使用云服务器 + Docker Compose,不增加登录或密码。 + +上线前准备: + +```bash +git pull +cp .env.example .env +``` + +在 `.env` 中填写真实 Key: + +```text +TIKHUB_API_KEY= +AI_BASE_URL= +AI_API_KEY= +AI_MODEL= +``` + +启动: + +```bash +docker compose up -d --build +curl -f http://127.0.0.1:8000/health +``` + +如果服务器安全组直接开放端口,公网入口为: + +```text +http://服务器公网 IP:8000 +``` + +也可以用 Nginx 反向代理到本机 `127.0.0.1:8000`。 + +注意: + +- 第一版公网不加访问控制,任何知道地址的人都可以访问页面。 +- 创建任务会消耗真实 TikHub 和 AI Key。 +- 系统仍保持同一时间只允许一个 running 任务,避免多人同时触发造成成本和稳定性问题。 + +## 初始化 Demo 数据 + +公网演示建议先初始化脱敏 Demo 数据: + +```bash +docker compose exec app python -m app.demo_seed +``` + +Demo 数据特点: + +- 使用脱敏真实感评论文本。 +- 不保存作者昵称、平台原始评论 ID、原始内容 URL 或 raw sensitive data。 +- 可以和新创建的真实任务同时出现在任务列表中。 + +## 数据库异常备份与恢复 + +如果页面出现数据库不可用提示,先不要删除 `data` 目录。 + +系统会优先备份现有 SQLite 文件到: + +```text +data/corrupt-backups/ +``` + +手动诊断: + +```bash +docker compose exec app python - <<'PY' +from app.db import engine +from sqlalchemy import text +with engine.connect() as c: + print(c.execute(text("PRAGMA integrity_check")).fetchall()) + print(c.exec_driver_sql("PRAGMA wal_checkpoint(TRUNCATE)").fetchall()) +PY +``` + +如果需要重新初始化演示数据: + +```bash +docker compose down +mv data/app.db data/corrupt-backups/app.db.manual.bak +rm -f data/app.db-wal data/app.db-shm +docker compose up -d --build +docker compose exec app python -m app.demo_seed +``` diff --git a/docs/MVP-WorkOrders.md b/docs/MVP-WorkOrders.md index 4add2fb..938ee66 100644 --- a/docs/MVP-WorkOrders.md +++ b/docs/MVP-WorkOrders.md @@ -900,3 +900,433 @@ WO-10 收尾与 P1 评估 验收结论:小红书 1×1×10 跑通,页面显示已完成,报告摘要正常 遗留问题:任务列表自动刷新和进度条仍待做 ``` + +--- + +## 13. MVP-2 新问题与产品化工单 + +本节记录 MVP 演示候选版本之后,用户在网页人工验收阶段发现的新问题和新增需求。 + +当前原则: + +- 不覆盖 WO-01 到 WO-10 的完成记录。 +- 新问题按 MVP-2 工单继续推进。 +- 阻塞主链路的问题优先修复。 +- 不清楚的产品决策必须先确认,不擅自替用户决定。 +- 每个工单完成后继续按“完成日期 / commit / 验证命令 / 验收结论 / 遗留问题”记录。 + +### MVP-2 工单总览 + +| 编号 | 工单 | 优先级 | 目标 | +|---|---|---|---| +| WO-11 | 全站 Internal Server Error 排查与兜底 | P0 | 解决点击按钮或刷新页面出现 500 的阻塞问题 | +| WO-12 | 导出 Markdown / CSV 点击失效修复 | P0 | 恢复报告和评论导出主链路 | +| WO-13 | 抓取任务进度透明化与耗时预期 | P0 | 降低长任务黑盒感,让用户知道任务是否真的在推进 | +| WO-14 | Demo 数据保留与新任务并存体验 | P0 | 打开页面即可看 demo 数据,同时还能新跑完整流程 | +| WO-15 | 公网部署方案与上线验收 | P0 | 从本机 Docker 演示推进到可公网访问的部署 | +| WO-16 | UI 产品化改版 | P1 | 在不破坏主链路的前提下提升页面观感和演示质感 | +| WO-17 | 默认规模数据量解释与展示优化 | P1 | 把“未达到 1250”解释为真实内容/评论不足,并在 UI 中清楚呈现 | + +### WO-11 全站 Internal Server Error 排查与兜底 + +优先级:P0 + +背景: + +- 用户反馈:每次点击一个按钮或刷新页面时,页面可能显示 `Internal Server Error`。 +- 这是主链路阻塞问题,必须优先定位。 + +目标: + +- 找出导致 500 的具体路由、异常堆栈和触发条件。 +- 对可恢复异常提供友好页面或错误提示,不让普通点击直接暴露 500。 +- 确保任务详情页、热点报告页、内容详情页、导出入口、刷新入口都不会因缺失数据直接崩溃。 + +包含范围: + +- 查看 Docker / Uvicorn 日志,记录 500 对应的异常堆栈。 +- 覆盖任务详情页刷新、任务列表刷新、热点报告页、内容详情页、导出入口等高频路由。 +- 对缺失任务、缺失热点、缺失内容、缺失报告、数据库读取异常等场景增加兜底。 +- 增加回归测试,覆盖已发现的 500 触发路径。 + +边界情况: + +- 任务 ID 不存在。 +- 报告记录不存在。 +- 内容条目存在但评论为空。 +- 任务处于 `running`,相关热点 / 内容 / 报告尚未生成。 +- 数据库暂时不可读或记录字段为空。 +- 浏览器重复刷新或重复点击按钮。 + +验收标准: + +- 已知触发 500 的页面和按钮不再返回 `Internal Server Error`。 +- 真实异常在服务端日志中可定位,前端展示友好提示。 +- `pytest tests/unit tests/integration -q` 通过。 +- Docker 环境下手动刷新任务详情页、报告页、内容页不出现 500。 + +建议 commit: + +```text +fix: 修复页面刷新和按钮点击的 500 错误 +``` + +待确认: + +- 用户需要提供或复现最容易触发 500 的页面 URL 与按钮名称;如果无法提供,则开发时先从当前浏览器打开的任务详情页开始排查。 + +### WO-12 导出 Markdown / CSV 点击失效修复 + +优先级:P0 + +背景: + +- 用户反馈:点击导出 Markdown 文档和导出 CSV 评论时,页面没有反应,像是按钮失效。 + +目标: + +- 恢复热点报告 Markdown、内容报告 Markdown、热点评论 CSV、内容评论 CSV 的下载能力。 +- 当报告尚未生成或数据为空时,按钮必须给出明确提示,而不是“点不动”。 + +包含范围: + +- 检查导出按钮的前端事件绑定、HTMX / 普通链接行为、下载路由返回头。 +- 检查导出路由是否返回正确的 `Content-Type` 和 `Content-Disposition`。 +- 检查浏览器端是否被 disabled 状态、JS 错误或 500 响应卡住。 +- 报告缺失时提供友好提示。 +- 评论为空时仍可下载只有表头的 CSV,或按用户决策改为提示无评论。 + +边界情况: + +- 报告尚未生成。 +- 评论数量为 0。 +- 文件名包含中文或特殊字符。 +- 浏览器拦截下载。 +- 导出接口返回 404 / 500。 +- 用户连续点击导出按钮。 + +验收标准: + +- 页面点击导出后浏览器能下载文件,或看到明确的不可导出原因。 +- CSV 可打开且含 UTF-8-BOM、公式注入防护、换行替换。 +- Markdown 内容与页面报告一致。 +- 导出失败不会造成整页 500。 + +建议 commit: + +```text +fix: 修复报告和评论导出点击失效 +``` + +待确认: + +- 评论为空时,用户希望“下载只有表头的 CSV”,还是“按钮禁用并提示暂无评论”。 + +### WO-13 抓取任务进度透明化与耗时预期 + +优先级:P0 + +背景: + +- 用户反馈:开始抓取后虽然显示运行中,但不知道具体需要多少时间,也不知道系统是否真的在抓取。 +- 当前体验仍有黑盒感,尤其是默认规模任务会等待较久。 + +目标: + +- 让任务详情页清楚展示当前阶段、已完成数量、失败数量、最近更新时间和粗略耗时预期。 +- 用户能判断任务是否仍在推进、是否卡住、卡在哪个阶段。 + +包含范围: + +- 后端记录或计算任务阶段: + - 获取热点中 + - 搜索内容中 + - 抓取评论中 + - AI 分析中 + - 生成报告中 + - 已完成 / 已失败 +- 展示当前进度: + - 热点:已获取 / 目标 + - 内容:已处理 / 总数 + - 评论:已抓取数量 + - AI:成功率 / 失败数 + - 报告:已生成数量 +- 展示任务开始时间、运行时长、最近更新时间。 +- 给出非承诺式耗时提示,例如“小规模通常较快,默认规模可能需要数分钟,取决于 TikHub 与 AI 响应速度”。 +- 如果一段时间没有进度更新,展示“可能仍在等待外部接口响应”的提示。 + +边界情况: + +- 刚开始运行,热点还没入库。 +- 已抓到热点但还没抓到内容。 +- 某个内容失败但任务继续。 +- 外部接口慢但未超时。 +- AI 请求慢。 +- Docker 重启导致任务中断。 + +验收标准: + +- 创建任务后,任务详情页能看到阶段和进度数字变化。 +- 用户不需要打开日志,也能知道任务大概处于哪个阶段。 +- 任务长时间无变化时有提示,不再只显示“正在抓取热点数据,请稍候...”。 +- 终态为 success / failed 后停止轮询。 + +建议 commit: + +```text +feat: 增强任务进度和运行阶段展示 +``` + +待确认: + +- 是否需要显示“预计剩余时间”。如果需要,建议第一版只显示粗略区间,不做精确倒计时,避免误导。 + +### WO-14 Demo 数据保留与新任务并存体验 + +优先级:P0 + +背景: + +- 用户希望别人打开后既能看到已经跑通的 demo 数据,又能自己创建任务跑完整流程。 +- 当前 `data/` 是本地运行数据,未纳入 Git,不能直接当作可交付 demo 数据方案。 + +目标: + +- 设计一套可控的 demo 数据方案,让页面首次打开就有可展示内容。 +- 同时保留用户创建新任务的能力。 + +可选方案: + +| 方案 | 描述 | 优点 | 风险 | +|---|---|---|---| +| A | 保留本机 `data/app.db` 作为本地演示数据库,不提交 Git | 最快,适合自己电脑演示 | 换机器或公网部署时不可复现 | +| B | 提供脱敏 seed 数据脚本,部署时生成 demo 任务 | 可复现,适合交付和公网部署 | 需要额外开发 seed 脚本 | +| C | 提供 demo JSON / fixture,首次启动导入 | 可控、可版本化 | 需要确认哪些真实数据可以脱敏保存 | + +包含范围: + +- 首页展示 demo 任务和真实新任务。 +- 标记 demo 数据来源,避免和真实新任务混淆。 +- 提供重置 demo 数据或清空本地任务的说明。 +- 确认不提交 API Key、用户隐私、敏感评论作者信息。 + +边界情况: + +- demo 数据和新抓取任务混在同一个任务列表。 +- 用户删除或重置数据库后 demo 数据消失。 +- 公网部署时没有本地 data 目录。 +- 真实评论内容可能包含敏感信息。 + +验收标准: + +- 新用户打开页面能看到可点击的 demo 任务。 +- 用户仍能创建新任务并进入运行中状态。 +- demo 数据不依赖真实 API Key。 +- demo 数据不包含敏感凭证。 + +建议 commit: + +```text +feat: 增加可复现 demo 数据 +``` + +待确认: + +- demo 数据使用真实抓取结果脱敏,还是使用模拟数据。 +- 是否允许在公网演示中展示真实评论文本和作者昵称。 +- demo 数据是否需要提供“一键恢复”能力。 + +### WO-15 公网部署方案与上线验收 + +优先级:P0 + +背景: + +- 用户明确需要公网部署,而不仅是本机 Docker 访问。 + +目标: + +- 选择并落地公网部署方式,提供可访问 URL。 +- 确保环境变量、数据目录、端口、健康检查、重启策略清楚可靠。 + +包含范围: + +- 确认部署目标: + - 云服务器 Docker Compose + - PaaS 平台 + - 内网穿透 / 临时演示链接 +- 配置环境变量和密钥管理。 +- 配置持久化数据目录。 +- 配置反向代理或公网端口。 +- 配置健康检查和重启策略。 +- 更新部署文档。 + +边界情况: + +- API Key 不应暴露在仓库或页面。 +- 公网访问可能产生额外抓取成本。 +- 多人同时点击创建任务。 +- SQLite 在公网多人使用下的并发限制。 +- 没有登录权限时,任何知道地址的人都能创建抓取任务。 + +验收标准: + +- 用户可以通过公网 URL 打开首页。 +- 公网环境能查看 demo 数据。 +- 公网环境能创建至少一个小规模任务。 +- 健康检查可访问。 +- 重启后数据不丢失,或文档明确说明数据生命周期。 + +建议 commit: + +```text +docs: 补充公网部署方案 +``` + +或如果包含实际部署配置: + +```text +chore: 增加公网部署配置 +``` + +待确认: + +- 部署平台选择。 +- 是否需要访问密码 / 简单登录。 +- 是否允许公网用户直接消耗真实 TikHub 和 AI Key。 +- 是否需要限制同一时间只能运行一个任务。 + +### WO-16 UI 产品化改版 + +优先级:P1 + +背景: + +- 用户明确希望后续修改 UI。 +- 当前页面主链路可用,但仍需要提升产品化观感。 + +目标: + +- 在不破坏功能的前提下,让首页、任务列表、任务详情、报告页、内容详情页更适合演示。 + +包含范围: + +- 首页信息架构优化。 +- 任务列表更像仪表盘。 +- 任务详情页突出进度、阶段、失败原因、AI 成功率。 +- 热点报告页和内容详情页优化阅读层次。 +- 按钮状态更清楚:可点击、加载中、禁用、失败。 +- 空状态更友好。 +- 移动端基础适配。 + +边界情况: + +- 文本过长。 +- 评论列表很多。 +- 无报告 / 无评论 / AI 失败。 +- 默认规模任务 running 很久。 +- 导出按钮不可用。 + +验收标准: + +- 页面不会出现文字重叠、按钮挤压、信息难以扫描。 +- 主要 CTA 明确。 +- 运行中和失败态清楚。 +- UI 改动不影响创建任务、查看报告和导出。 + +建议 commit: + +```text +feat: 优化 MVP 页面产品化体验 +``` + +待确认: + +- UI 风格方向:更偏数据仪表盘、内部工具,还是偏演示型产品页面。 +- 是否需要提供简单品牌名 / Logo / 说明文案。 + +### WO-17 默认规模数据量解释与展示优化 + +优先级:P1 + +背景: + +- 默认规模理论值是 `5×5×50=1250` 评论,但真实任务不一定达到。 +- 用户判断这更可能是笔记 / 视频本身评论不足,而不是接口错误。 + +目标: + +- 在 UI 和文档中清楚解释“目标上限”和“实际返回”的区别。 +- 避免用户看到少于 1250 就误以为任务失败。 + +包含范围: + +- 任务详情页展示: + - 配置目标:5 热点 × 5 内容 × 50 评论 + - 实际结果:实际热点、实际内容、实际评论 + - 不足原因提示:平台内容 / 评论不足、接口返回不足、部分内容失败 +- 报告页展示样本数,避免将样本不足包装成完整全量分析。 +- 文档补充默认规模解释。 + +边界情况: + +- 某个热点只有少量内容。 +- 某条内容本身评论不足 50。 +- API 返回空评论但内容存在。 +- 部分内容失败导致评论不足。 + +验收标准: + +- 用户能区分“抓取上限”和“实际抓到数量”。 +- 默认规模任务少于 1250 时,页面给出合理说明。 +- 真实失败和自然不足有不同提示。 + +建议 commit: + +```text +feat: 展示默认规模目标与实际抓取差异 +``` + +待确认: + +- 是否需要在报告里显示“样本不足,不代表完整舆情”的提示。 + +## 14. MVP-2 推荐执行顺序 + +建议先处理阻塞主链路和演示可信度的问题: + +```text +WO-11 全站 500 排查与兜底 + ↓ +WO-12 导出点击失效修复 + ↓ +WO-13 抓取进度透明化 + ↓ +WO-14 Demo 数据方案 + ↓ +WO-15 公网部署 + ↓ +WO-16 UI 产品化改版 + ↓ +WO-17 默认规模数据量解释优化 +``` + +原因: + +- 500 和导出失效会直接破坏验收,优先级最高。 +- 进度透明化解决“黑盒运行”的核心体验问题。 +- Demo 数据和公网部署强相关,应该在部署前明确。 +- UI 改版适合在主链路稳定后进行,避免把 bug 和视觉修改混在一起。 + +## 15. MVP-2 待用户确认问题 + +以下问题需要用户确认后再进入对应工单开发: + +1. 公网部署选择哪种方式:云服务器 Docker Compose、PaaS、还是临时内网穿透演示。 +2. 公网访问是否需要密码 / 简单登录。 +3. 公网用户是否允许直接消耗真实 TikHub 和 AI Key。 +4. Demo 数据使用真实抓取结果脱敏,还是使用模拟数据。 +5. 公网 demo 是否允许展示真实评论文本和作者昵称。 +6. 评论为空时,CSV 导出是下载只有表头的文件,还是按钮禁用并提示暂无评论。 +7. 任务进度是否需要显示预计剩余时间,还是只显示阶段、运行时长和最近更新时间。 +8. UI 风格方向:内部数据仪表盘,还是更偏演示型产品页面。 diff --git a/pyproject.toml b/pyproject.toml index 606c455..a4e7822 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,10 @@ dev = [ testpaths = ["tests"] pythonpath = ["."] +[tool.setuptools.packages.find] +include = ["app*"] +exclude = ["data*", "docs*", "tests*"] + [tool.coverage.run] branch = true source = ["app"] diff --git a/tests/integration/test_routes.py b/tests/integration/test_routes.py index 2a98360..8d599d3 100644 --- a/tests/integration/test_routes.py +++ b/tests/integration/test_routes.py @@ -1,5 +1,6 @@ from app.models import Comment, ContentItem, Hotspot, Report, Task from tests.helpers import make_test_client +from sqlalchemy.exc import OperationalError def test_index_page_renders_task_form_empty_state_and_default_scale(): @@ -354,3 +355,59 @@ def test_export_routes_return_csv_and_markdown(): assert "热点总结" in hotspot_md.text assert item_md.status_code == 200 assert "内容总结" in item_md.text + + +def test_api_tasks_returns_service_unavailable_when_database_has_io_error(monkeypatch): + def broken_list_tasks(_session): + raise OperationalError("SELECT 1", {}, Exception("disk I/O error")) + + monkeypatch.setattr("app.main.list_tasks", broken_list_tasks) + + with make_test_client() as (client, _engine): + response = client.get("/api/tasks") + + assert response.status_code == 503 + assert response.json()["detail"] == "数据库暂时不可用,请稍后重试或联系维护者恢复数据。" + + +def test_index_page_shows_database_error_state_when_database_has_io_error(monkeypatch): + def broken_list_tasks(_session): + raise OperationalError("SELECT 1", {}, Exception("disk I/O error")) + + monkeypatch.setattr("app.main.list_tasks", broken_list_tasks) + + with make_test_client() as (client, _engine): + response = client.get("/") + + assert response.status_code == 503 + assert "数据库暂时不可用" in response.text + assert "请先保留 data 目录" in response.text + + +def test_task_api_includes_progress_counts_stage_and_demo_flag(): + with make_test_client() as (client, engine): + seed_result_data(engine) + + response = client.get("/api/tasks/task-result") + + assert response.status_code == 200 + data = response.json() + assert data["current_stage"] in (None, "success") + assert data["current_stage_label"] == "已完成" + assert data["comments_count"] == 1 + assert data["reports_count"] == 2 + assert data["is_demo"] is False + assert data["last_progress_at"] is not None + + +def test_task_detail_page_explains_target_and_actual_counts(): + with make_test_client() as (client, engine): + seed_result_data(engine) + + response = client.get("/tasks/task-result") + + assert response.status_code == 200 + assert "目标上限" in response.text + assert "实际结果" in response.text + assert "实际评论 1" in response.text + assert "少于理论上限通常是内容本身评论不足或平台返回不足" in response.text diff --git a/tests/unit/test_db_stability.py b/tests/unit/test_db_stability.py index 8003fc3..6f5bd0e 100644 --- a/tests/unit/test_db_stability.py +++ b/tests/unit/test_db_stability.py @@ -1,6 +1,6 @@ import pytest -from app.db import check_database_integrity, checkpoint_sqlite_wal, create_sqlite_engine +from app.db import check_database_integrity, checkpoint_sqlite_wal, create_sqlite_engine, ensure_sqlite_schema_compat def test_check_database_integrity_returns_ok_for_valid_sqlite_database(): @@ -63,3 +63,20 @@ def test_checkpoint_sqlite_wal_skips_in_memory_database(): assert checkpoint_sqlite_wal(engine) is False finally: engine.dispose() + + +def test_ensure_sqlite_schema_compat_adds_progress_columns_to_existing_tasks_table(tmp_path): + db_path = tmp_path / "legacy.db" + engine = create_sqlite_engine(f"sqlite:///{db_path}") + try: + with engine.begin() as connection: + connection.exec_driver_sql("CREATE TABLE tasks (id VARCHAR(36) PRIMARY KEY, platform VARCHAR(32) NOT NULL)") + + ensure_sqlite_schema_compat(engine) + + with engine.connect() as connection: + columns = {row[1] for row in connection.exec_driver_sql("PRAGMA table_info(tasks)").all()} + assert "current_stage" in columns + assert "last_progress_at" in columns + finally: + engine.dispose() diff --git a/tests/unit/test_demo_seed.py b/tests/unit/test_demo_seed.py new file mode 100644 index 0000000..76a89ad --- /dev/null +++ b/tests/unit/test_demo_seed.py @@ -0,0 +1,27 @@ +from sqlalchemy.orm import Session + +from app.demo_seed import seed_demo_data +from app.models import Comment, ContentItem, Task +from tests.helpers import make_test_client + + +def test_seed_demo_data_is_idempotent_and_removes_sensitive_fields(): + with make_test_client() as (_client, engine): + with Session(engine) as session: + task_id = seed_demo_data(session) + second_task_id = seed_demo_data(session) + + task = session.get(Task, task_id) + comments = session.query(Comment).all() + items = session.query(ContentItem).all() + + assert second_task_id == task_id + assert task is not None + assert task.id.startswith("demo-") + assert task.status == "success" + assert comments + assert all(comment.author is None for comment in comments) + assert all(comment.source_comment_id is None for comment in comments) + assert all(comment.raw_data == "{}" for comment in comments) + assert all(item.url is None for item in items) + assert all(item.raw_data == "{}" for item in items) diff --git a/tests/unit/test_docs.py b/tests/unit/test_docs.py index f3594a0..f76b48b 100644 --- a/tests/unit/test_docs.py +++ b/tests/unit/test_docs.py @@ -17,5 +17,8 @@ def test_user_guide_covers_startup_acceptance_exports_and_troubleshooting(): "API Key 缺失", "任务长期 running", "重置本地数据库", + "公网云服务器部署", + "python -m app.demo_seed", + "data/corrupt-backups", ]: assert required in content
{{ task.id }} + {{ task.id }} + {% if task.is_demo %} +
Demo 数据 + {% endif %} +
{{ task.platform | platform_label }} {{ task.created_at }} 热点 {{ task.hotspot_limit }} / 内容 {{ task.item_limit_per_hotspot }} / 评论 {{ task.comment_limit_per_item }}