feat: 完善 MVP-2 演示和进度体验

This commit is contained in:
meijiali
2026-07-03 16:57:39 +08:00
parent 867fd39419
commit 9935f67080
20 changed files with 1252 additions and 33 deletions
+41
View File
@@ -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)
+123
View File
@@ -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()
+157
View File
@@ -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该内容的评论更关注真实体验和后续转化,用户希望看到长期反馈,也表现出明显的购买链接需求。"
}
]
}
+33 -2
View File
@@ -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"}
+2
View File
@@ -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)
+6
View File
@@ -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
+56 -3
View File
@@ -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
+95 -1
View File
@@ -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;
}
}
+24 -2
View File
@@ -114,9 +114,23 @@ function pollTaskListStatus() {
async function downloadExport(url, defaultFilename, event) {
if (event) event.preventDefault();
const resp = await fetch(url);
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) {
alert("导出失败,请稍后重试。");
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();
@@ -131,4 +145,12 @@ async function downloadExport(url, defaultFilename, event) {
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
} catch (_error) {
alert("网络异常,导出未完成。");
} finally {
if (button) {
button.disabled = false;
button.textContent = originalText;
}
}
}
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}数据库暂时不可用 - 热榜评论分析工具{% endblock %}
{% block content %}
<section class="status-panel status-panel-danger">
<div>
<p class="eyebrow mb-2">数据恢复保护</p>
<h1 class="h3 mb-3">数据库暂时不可用</h1>
<p class="mb-3">{{ message }}</p>
<p class="mb-0 text-muted">请先保留 data 目录,不要删除 app.db、app.db-wal 或 app.db-shm。系统会优先备份现有数据库文件,再进行诊断和恢复。</p>
</div>
</section>
{% if detail %}
<details class="mt-3">
<summary class="text-muted">查看技术细节</summary>
<pre class="mt-2 small">{{ detail }}</pre>
</details>
{% endif %}
{% endblock %}
+18 -8
View File
@@ -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 %}
<div class="d-flex justify-content-between align-items-center mb-3">
<h1 class="h3 mb-0">任务列表</h1>
<button class="btn btn-outline-secondary btn-sm" onclick="window.location.href='/'">手动刷新</button>
</div>
<section class="hero-band mb-4">
<div class="hero-copy">
<p class="eyebrow">Hot Comment Radar</p>
<h1>热榜评论雷达</h1>
<p>从小红书和抖音热点出发,抓取真实评论,生成 AI 情绪、标签和可导出的分析报告。</p>
</div>
<div class="hero-actions">
<a class="btn btn-light" href="#create-task">创建新任务</a>
<a class="btn btn-outline-light" href="#task-history">查看 Demo 数据</a>
</div>
</section>
<section class="card mb-4">
<section class="card mb-4" id="create-task">
<div class="card-header">创建抓取任务</div>
<div class="card-body">
<div id="form-error" class="alert alert-danger d-none" role="alert"></div>
@@ -43,8 +50,11 @@
</div>
</section>
<section class="card">
<div class="card-header">历史任务</div>
<section class="card" id="task-history">
<div class="card-header d-flex justify-content-between align-items-center">
<span>最近任务与 Demo 数据</span>
<button class="btn btn-outline-secondary btn-sm" onclick="window.location.href='/'">手动刷新</button>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
+8 -1
View File
@@ -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) %}
<tr>
<td><code>{{ task.id }}</code></td>
<td>
<code>{{ task.id }}</code>
{% if task.is_demo %}
<br><span class="badge text-bg-info">Demo 数据</span>
{% endif %}
</td>
<td>{{ task.platform | platform_label }}</td>
<td>{{ task.created_at }}</td>
<td><small class="text-muted">热点 {{ task.hotspot_limit }} / 内容 {{ task.item_limit_per_hotspot }} / 评论 {{ task.comment_limit_per_item }}</small></td>
@@ -16,6 +21,8 @@
<div class="progress-bar" style="width: {{ percent }}%"></div>
</div>
<div class="small text-muted mt-1">成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}</div>
<div class="small text-muted mt-1">阶段:{{ task.current_stage_label or "等待启动" }}</div>
<div class="small text-muted mt-1">评论 {{ task.comments_count or 0 }} / 报告 {{ task.reports_count or 0 }}</div>
<div class="small {% if task.analysis_status == 'insufficient' %}text-warning{% else %}text-muted{% endif %}">
AI 成功率 {{ ai_percent }}%
{% if task.analysis_status == "insufficient" %}
+31 -1
View File
@@ -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) %}
<p>平台:{{ task.platform | platform_label }} <span class="badge {{ cls }}">{{ label }}</span></p>
{% set target_comments = task.hotspot_limit * task.item_limit_per_hotspot * task.comment_limit_per_item %}
<div class="d-flex flex-wrap justify-content-between gap-3 mb-3">
<p class="mb-0">平台:{{ task.platform | platform_label }} <span class="badge {{ cls }}">{{ label }}</span>{% if task.is_demo %}<span class="badge text-bg-info ms-1">Demo 数据</span>{% endif %}</p>
<p class="mb-0 text-muted">阶段:{{ task.current_stage_label or "等待启动" }}</p>
</div>
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center gap-2">
<span>已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容</span>
@@ -26,6 +30,32 @@
</div>
<div class="small text-muted mt-2">成功 {{ task.successful_items_count }} / 失败 {{ task.failed_items_count }}</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<div class="metric-box">
<div class="text-muted small">目标上限</div>
<strong>{{ task.hotspot_limit }} 热点 × {{ task.item_limit_per_hotspot }} 内容 × {{ task.comment_limit_per_item }} 评论</strong>
<div class="text-muted small">最多 {{ target_comments }} 条评论</div>
</div>
</div>
<div class="col-md-4">
<div class="metric-box">
<div class="text-muted small">实际结果</div>
<strong>实际评论 {{ task.comments_count or 0 }}</strong>
<div class="text-muted small">报告 {{ task.reports_count or 0 }} / 内容 {{ task.total_items_count }}</div>
</div>
</div>
<div class="col-md-4">
<div class="metric-box">
<div class="text-muted small">最近进度</div>
<strong>{{ task.last_progress_at or task.created_at }}</strong>
<div class="text-muted small">外部接口和 AI 响应较慢时,阶段可能短时间不变</div>
</div>
</div>
</div>
{% if task.status == "success" and (task.comments_count or 0) < target_comments %}
<div class="alert alert-info">少于理论上限通常是内容本身评论不足或平台返回不足,不直接代表任务失败。若失败内容数大于 0,请结合失败原因判断。</div>
{% endif %}
<div class="mb-2">
<span class="{% if task.analysis_status == 'insufficient' %}text-warning{% else %}text-muted{% endif %}">AI 成功率 {{ ai_percent }}%</span>
{% if task.analysis_status == "insufficient" %}
+87
View File
@@ -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
```
+430
View File
@@ -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 风格方向:内部数据仪表盘,还是更偏演示型产品页面。
+4
View File
@@ -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"]
+57
View File
@@ -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
+18 -1
View File
@@ -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()
+27
View File
@@ -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)
+3
View File
@@ -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