Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
918dd62e21 | ||
|
|
1d24cbf841 | ||
|
|
9d212b54be | ||
|
|
c406364d14 | ||
|
|
89dcf49186 | ||
|
|
78cc5df73d | ||
|
|
da69819b56 | ||
|
|
7371c45fb4 | ||
|
|
bac5304481 | ||
|
|
1f68cee99c | ||
|
|
aec3604de9 | ||
|
|
0b186fa9f8 | ||
|
|
07ce3d4fcd | ||
|
|
4102ecc4e3 | ||
|
|
b70b840551 | ||
|
|
2dc8ca1b5b |
@@ -8,3 +8,4 @@ htmlcov/
|
||||
.env
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
data/corrupt-backups/
|
||||
|
||||
+2
-3
@@ -7,9 +7,8 @@ WORKDIR /app
|
||||
|
||||
RUN adduser --disabled-password --gecos "" appuser
|
||||
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN pip install --no-cache-dir uv \
|
||||
&& uv pip install --system --no-cache .
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
COPY .env.example ./.env.example
|
||||
|
||||
@@ -5,6 +5,7 @@ from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import Engine, create_engine, event
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
@@ -20,7 +21,10 @@ def create_sqlite_engine(database_url: str):
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
connect_args = {"check_same_thread": False, "timeout": 10}
|
||||
engine = create_engine(database_url, connect_args=connect_args)
|
||||
engine_kwargs = {"connect_args": connect_args}
|
||||
if database_url.startswith("sqlite:///") and database_url != "sqlite:///:memory:":
|
||||
engine_kwargs["poolclass"] = NullPool
|
||||
engine = create_engine(database_url, **engine_kwargs)
|
||||
engine.dialect.connect_args = connect_args
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
|
||||
+2
-47
@@ -1,11 +1,9 @@
|
||||
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, JSONResponse
|
||||
from fastapi.responses import Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy.exc import OperationalError, SQLAlchemyError
|
||||
from sqlalchemy import select
|
||||
@@ -14,7 +12,6 @@ from sqlalchemy.orm import Session
|
||||
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
|
||||
from app.services.task_service import (
|
||||
RUNNING_TASK_MESSAGE,
|
||||
create_task,
|
||||
@@ -22,6 +19,7 @@ from app.services.task_service import (
|
||||
has_running_task,
|
||||
list_tasks,
|
||||
recover_running_tasks,
|
||||
recover_stale_running_tasks,
|
||||
)
|
||||
from app.templating import templates
|
||||
|
||||
@@ -86,6 +84,7 @@ def create_task_api(
|
||||
request: CreateTaskRequest,
|
||||
session: Session = Depends(get_db_session),
|
||||
) -> CreateTaskResponse:
|
||||
recover_stale_running_tasks(session)
|
||||
if has_running_task(session):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=RUNNING_TASK_MESSAGE)
|
||||
|
||||
@@ -146,47 +145,3 @@ def item_detail_page(item_id: str, request: Request, session: Session = Depends(
|
||||
"items/detail.html",
|
||||
{"task": task, "hotspot": hotspot, "item": item, "report": report, "comments": comments},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/export/items/{item_id}/comments.csv")
|
||||
def export_item_comments(item_id: str, session: Session = Depends(get_db_session)) -> Response:
|
||||
try:
|
||||
content, filename = export_item_comments_csv(session, item_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导出数据不存在")
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/export/hotspots/{hotspot_id}/comments.csv")
|
||||
def export_hotspot_comments(hotspot_id: str, session: Session = Depends(get_db_session)) -> Response:
|
||||
try:
|
||||
content, filename = export_hotspot_comments_csv(session, hotspot_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="导出数据不存在")
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/export/hotspots/{hotspot_id}.md")
|
||||
def export_hotspot_markdown(hotspot_id: str, session: Session = Depends(get_db_session)) -> Response:
|
||||
try:
|
||||
content, filename = export_report_markdown(session, report_type="hotspot", hotspot_id=hotspot_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报告不存在")
|
||||
return Response(content=content, media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"})
|
||||
|
||||
|
||||
@app.get("/api/export/items/{item_id}.md")
|
||||
def export_item_markdown(item_id: str, session: Session = Depends(get_db_session)) -> Response:
|
||||
try:
|
||||
content, filename = export_report_markdown(session, report_type="item", item_id=item_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="报告不存在")
|
||||
return Response(content=content, media_type="text/markdown; charset=utf-8", headers={"Content-Disposition": f"attachment; filename*=UTF-8''{quote(filename)}"})
|
||||
|
||||
@@ -87,6 +87,12 @@ class TikHubClient:
|
||||
)
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
if response.status_code == 401:
|
||||
raise PlatformAPIError(
|
||||
"TikHub 鉴权失败,请检查 TIKHUB_API_KEY 是否有效",
|
||||
error_type="auth_error",
|
||||
status_code=response.status_code,
|
||||
)
|
||||
if response.is_error:
|
||||
raise PlatformAPIError(
|
||||
f"External API returned HTTP {response.status_code}",
|
||||
|
||||
@@ -34,6 +34,11 @@ class TaskResponse(BaseModel):
|
||||
current_stage: str | None = None
|
||||
current_stage_label: str | None = None
|
||||
last_progress_at: datetime | None = None
|
||||
display_number: int = 0
|
||||
display_id: str = ""
|
||||
created_at_label: str = ""
|
||||
scale_label: str = ""
|
||||
error_summary: str | None = None
|
||||
running_seconds: int = 0
|
||||
seconds_since_last_progress: int | None = None
|
||||
running_duration_label: str = "刚刚"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
@@ -121,12 +122,14 @@ def analyze_comments_with_retry(
|
||||
*,
|
||||
requester,
|
||||
max_retries: int = 3,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> list[AIAnalysisResult]:
|
||||
expected_ids = {str(comment["comment_id"]) for comment in comments}
|
||||
prompt = build_comment_prompt(comments)
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
return parse_ai_comment_response(requester(prompt), expected_comment_ids=expected_ids)
|
||||
response_text = _request_with_timeout(requester, prompt, timeout_seconds=timeout_seconds)
|
||||
return parse_ai_comment_response(response_text, expected_comment_ids=expected_ids)
|
||||
except Exception:
|
||||
if attempt >= max_retries - 1:
|
||||
break
|
||||
@@ -143,6 +146,21 @@ def analyze_comments_with_retry(
|
||||
]
|
||||
|
||||
|
||||
def _request_with_timeout(requester, prompt: str, *, timeout_seconds: float | None) -> str:
|
||||
if timeout_seconds is None or timeout_seconds <= 0:
|
||||
return requester(prompt)
|
||||
|
||||
executor = ThreadPoolExecutor(max_workers=1)
|
||||
future = executor.submit(requester, prompt)
|
||||
try:
|
||||
return future.result(timeout=timeout_seconds)
|
||||
except TimeoutError as exc:
|
||||
future.cancel()
|
||||
raise TimeoutError("AI request timed out") from exc
|
||||
finally:
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
|
||||
class OpenAICompatibleAIClient:
|
||||
def __init__(
|
||||
self,
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import Comment, ContentItem, Hotspot, Report
|
||||
|
||||
|
||||
def labels_to_text(labels_json: str | None) -> str:
|
||||
try:
|
||||
labels = json.loads(labels_json or "[]")
|
||||
except json.JSONDecodeError:
|
||||
return ""
|
||||
return ",".join(str(label) for label in labels) if isinstance(labels, list) else ""
|
||||
|
||||
|
||||
def safe_csv_field(value) -> str:
|
||||
text = "" if value is None else str(value)
|
||||
text = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
|
||||
if text.startswith(("=", "+", "-", "@")):
|
||||
return f"'{text}"
|
||||
return text
|
||||
|
||||
|
||||
def safe_filename(platform: str, task_id: str, hotspot_keyword: str, *, extension: str = "csv") -> str:
|
||||
keyword = hotspot_keyword[:20] + ("..." if len(hotspot_keyword) > 20 else "")
|
||||
name = f"{platform}_{task_id}_{keyword}"
|
||||
name = re.sub(r'[/\\:*?"<>|]+', "_", name)
|
||||
name = re.sub(r"_+", "_", name).strip("_")
|
||||
return f"{name}.{extension}"
|
||||
|
||||
|
||||
def export_item_comments_csv(session: Session, item_id: str) -> tuple[bytes, str]:
|
||||
item = session.get(ContentItem, item_id)
|
||||
if item is None:
|
||||
raise ValueError("content item not found")
|
||||
hotspot = session.get(Hotspot, item.hotspot_id)
|
||||
comments = list(session.scalars(select(Comment).where(Comment.content_item_id == item.id)))
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["平台", "任务 ID", "热点 ID", "热点标题", "内容条目 ID", "内容条目标题", "评论 ID", "评论内容", "情绪倾向", "方向标签", "点赞数", "评论时间"])
|
||||
for comment in comments:
|
||||
writer.writerow(
|
||||
[
|
||||
item.platform,
|
||||
item.task_id,
|
||||
hotspot.id if hotspot else "",
|
||||
hotspot.title if hotspot else "",
|
||||
item.id,
|
||||
item.title or "",
|
||||
comment.source_comment_id or "",
|
||||
safe_csv_field(comment.content),
|
||||
comment.sentiment,
|
||||
labels_to_text(comment.labels),
|
||||
comment.like_count or 0,
|
||||
comment.comment_time or "",
|
||||
]
|
||||
)
|
||||
filename = safe_filename(item.platform, item.task_id, hotspot.title if hotspot else item.title or "comments")
|
||||
return output.getvalue().encode("utf-8-sig"), filename
|
||||
|
||||
|
||||
def export_hotspot_comments_csv(session: Session, hotspot_id: str) -> tuple[bytes, str]:
|
||||
hotspot = session.get(Hotspot, hotspot_id)
|
||||
if hotspot is None:
|
||||
raise ValueError("hotspot not found")
|
||||
comments = list(session.scalars(select(Comment).where(Comment.hotspot_id == hotspot.id)))
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["平台", "任务 ID", "热点 ID", "热点标题", "内容条目 ID", "内容条目标题", "评论 ID", "评论内容", "情绪倾向", "方向标签", "点赞数", "评论时间"])
|
||||
for comment in comments:
|
||||
item = session.get(ContentItem, comment.content_item_id)
|
||||
writer.writerow(
|
||||
[
|
||||
comment.platform,
|
||||
comment.task_id,
|
||||
hotspot.id,
|
||||
hotspot.title,
|
||||
item.id if item else "",
|
||||
item.title if item else "",
|
||||
comment.source_comment_id or "",
|
||||
safe_csv_field(comment.content),
|
||||
comment.sentiment,
|
||||
labels_to_text(comment.labels),
|
||||
comment.like_count or 0,
|
||||
comment.comment_time or "",
|
||||
]
|
||||
)
|
||||
return output.getvalue().encode("utf-8-sig"), safe_filename(hotspot.platform, hotspot.task_id, hotspot.title)
|
||||
|
||||
|
||||
def export_report_markdown(session: Session, *, report_type: str, hotspot_id: str | None = None, item_id: str | None = None) -> tuple[str, str]:
|
||||
query = select(Report).where(Report.report_type == report_type)
|
||||
if hotspot_id:
|
||||
query = query.where(Report.hotspot_id == hotspot_id)
|
||||
if item_id:
|
||||
query = query.where(Report.content_item_id == item_id)
|
||||
report = session.scalar(query)
|
||||
if report is None:
|
||||
raise ValueError("report not found")
|
||||
return report.markdown_content or report.markdown, safe_filename("report", report.task_id, report.title, extension="md")
|
||||
@@ -3,6 +3,7 @@ import json
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
@@ -21,6 +22,8 @@ from app.services.report_service import generate_hotspot_report, generate_item_r
|
||||
RUNNING_TASK_MESSAGE = "当前有正在运行的任务,请稍后再试"
|
||||
RESTART_ERROR_MESSAGE = "系统重启,任务被中断"
|
||||
STALE_PROGRESS_THRESHOLD_SECONDS = 10 * 60
|
||||
STALE_PROGRESS_ERROR_TYPE = "stale_progress_timeout"
|
||||
DISPLAY_TIMEZONE = ZoneInfo("Asia/Shanghai")
|
||||
task_executor = ThreadPoolExecutor(max_workers=1)
|
||||
|
||||
|
||||
@@ -34,12 +37,40 @@ STAGE_LABELS = {
|
||||
"success": "已完成",
|
||||
"failed": "失败",
|
||||
}
|
||||
PLATFORM_LABELS = {
|
||||
"xiaohongshu": "小红书",
|
||||
"douyin": "抖音",
|
||||
}
|
||||
|
||||
|
||||
def has_running_task(session: Session) -> bool:
|
||||
return session.scalar(select(Task.id).where(Task.status == "running").limit(1)) is not None
|
||||
|
||||
|
||||
def recover_stale_running_tasks(session: Session) -> int:
|
||||
now = ensure_utc_datetime(utc_now())
|
||||
tasks = list(session.scalars(select(Task).where(Task.status == "running")))
|
||||
recovered = 0
|
||||
for task in tasks:
|
||||
last_progress_at = ensure_utc_datetime(task.last_progress_at or task.started_at or task.created_at)
|
||||
if last_progress_at is None or now is None:
|
||||
continue
|
||||
seconds_since_progress = max(0, int((now - last_progress_at).total_seconds()))
|
||||
if seconds_since_progress < STALE_PROGRESS_THRESHOLD_SECONDS:
|
||||
continue
|
||||
_mark_task_failed(
|
||||
task,
|
||||
"system",
|
||||
STALE_PROGRESS_ERROR_TYPE,
|
||||
f"任务超过 {STALE_PROGRESS_THRESHOLD_SECONDS // 60} 分钟没有进度更新,已自动标记失败",
|
||||
)
|
||||
task.finished_at = task.finished_at or utc_now()
|
||||
recovered += 1
|
||||
if recovered:
|
||||
session.commit()
|
||||
return recovered
|
||||
|
||||
|
||||
def recover_running_tasks(session: Session) -> int:
|
||||
tasks = list(session.scalars(select(Task).where(Task.status == "running")))
|
||||
for task in tasks:
|
||||
@@ -150,6 +181,12 @@ def get_task(session: Session, task_id: str) -> Task | None:
|
||||
|
||||
|
||||
def hydrate_task_progress(session: Session, task: Task) -> Task:
|
||||
display_number = calculate_task_display_number(session, task)
|
||||
task.display_number = display_number
|
||||
task.display_id = str(display_number)
|
||||
task.created_at_label = format_datetime_minute(task.created_at)
|
||||
task.scale_label = f"{task.hotspot_limit}热点 × {task.item_limit_per_hotspot}内容 × {task.comment_limit_per_item}评论"
|
||||
task.error_summary = build_task_error_summary(task)
|
||||
task.hotspots_count = session.scalar(select(func.count(Hotspot.id)).where(Hotspot.task_id == task.id)) or 0
|
||||
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
|
||||
@@ -182,6 +219,29 @@ def hydrate_task_progress(session: Session, task: Task) -> Task:
|
||||
return task
|
||||
|
||||
|
||||
def calculate_task_display_number(session: Session, task: Task) -> int:
|
||||
task_ids = list(session.scalars(select(Task.id).order_by(Task.created_at, Task.id)))
|
||||
try:
|
||||
return task_ids.index(task.id) + 1
|
||||
except ValueError:
|
||||
return len(task_ids) + 1
|
||||
|
||||
|
||||
def format_datetime_minute(value: datetime | None) -> str:
|
||||
value = ensure_utc_datetime(value)
|
||||
return value.astimezone(DISPLAY_TIMEZONE).strftime("%Y-%m-%d %H:%M") if value else ""
|
||||
|
||||
|
||||
def build_task_error_summary(task: Task) -> str | None:
|
||||
if task.error_message:
|
||||
return task.error_message
|
||||
if task.error_stage == "system" and task.error_type == "unexpected_restart":
|
||||
return RESTART_ERROR_MESSAGE
|
||||
if task.error_stage or task.error_type:
|
||||
return "任务失败,请查看后端日志"
|
||||
return None
|
||||
|
||||
|
||||
def format_duration_zh(total_seconds: int | None) -> str:
|
||||
if total_seconds is None:
|
||||
return "暂无记录"
|
||||
@@ -220,6 +280,8 @@ def run_task(task_id: str, *, session_factory: Callable[[], Session] | sessionma
|
||||
task = session.get(Task, task_id)
|
||||
if task is None:
|
||||
return
|
||||
task.started_at = task.started_at or utc_now()
|
||||
session.commit()
|
||||
try:
|
||||
platform = build_platform(task.platform)
|
||||
ai_requester, report_summary_provider = build_ai_dependencies()
|
||||
@@ -309,6 +371,7 @@ def run_task(task_id: str, *, session_factory: Callable[[], Session] | sessionma
|
||||
[{"comment_id": comment.id, "content": comment.content} for comment in item_comments],
|
||||
requester=ai_requester,
|
||||
max_retries=get_settings().ai_max_retries,
|
||||
timeout_seconds=get_settings().ai_timeout_seconds,
|
||||
)
|
||||
results_by_comment_id = {result.comment_id: result for result in ai_results}
|
||||
for comment in item_comments:
|
||||
@@ -354,9 +417,11 @@ def run_task(task_id: str, *, session_factory: Callable[[], Session] | sessionma
|
||||
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 "没有任何内容条目成功")
|
||||
task.finished_at = task.finished_at or utc_now()
|
||||
session.commit()
|
||||
except Exception as exc:
|
||||
_mark_task_failed(task, "system", "unexpected_error", str(exc))
|
||||
task.finished_at = task.finished_at or utc_now()
|
||||
session.commit()
|
||||
finally:
|
||||
if close_session:
|
||||
|
||||
+116
-14
@@ -1,16 +1,63 @@
|
||||
body {
|
||||
background: #f5f7fb;
|
||||
color: #172033;
|
||||
background:
|
||||
linear-gradient(180deg, #f4f7fb 0%, #eef3f8 42%, #f8fafc 100%);
|
||||
color: #182235;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-navbar {
|
||||
background: rgba(255, 255, 255, 0.94) !important;
|
||||
border-bottom: 1px solid #dfe7f1;
|
||||
box-shadow: 0 10px 30px rgba(19, 34, 56, 0.04);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: #102033;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #526173;
|
||||
}
|
||||
|
||||
.tool-card,
|
||||
.card {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-radius: 8px;
|
||||
border: 1px solid #dfe5ef;
|
||||
box-shadow: 0 10px 28px rgba(31, 42, 68, 0.06);
|
||||
border: 1px solid #dbe4ef;
|
||||
box-shadow: 0 16px 42px rgba(31, 42, 68, 0.07);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: #fff !important;
|
||||
.card-header,
|
||||
.section-heading {
|
||||
background: linear-gradient(180deg, #ffffff, #f8fafc);
|
||||
border-bottom: 1px solid #e4ebf3;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-heading h2,
|
||||
.page-heading h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.section-kicker,
|
||||
.eyebrow {
|
||||
color: #0e766e;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
margin: 0 0 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hero-band {
|
||||
@@ -20,7 +67,9 @@ body {
|
||||
url("https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1600&q=80");
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 24px 60px rgba(15, 36, 62, 0.18);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -52,20 +101,67 @@ body {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
color: #71d4c7;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
.page-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metric-box {
|
||||
background: #f8fafc;
|
||||
background: linear-gradient(180deg, #f9fbfd, #f3f7fb);
|
||||
border: 1px solid #e3e9f2;
|
||||
border-radius: 8px;
|
||||
height: 100%;
|
||||
padding: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.metric-box strong {
|
||||
color: #132033;
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.app-table thead th {
|
||||
background: #f8fafc;
|
||||
color: #536174;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.app-table tbody tr {
|
||||
border-color: #e8eef5;
|
||||
}
|
||||
|
||||
.app-table tbody tr:hover {
|
||||
background: #f7fbff;
|
||||
}
|
||||
|
||||
.task-number {
|
||||
align-items: center;
|
||||
background: #e7f0ff;
|
||||
border: 1px solid #cfe0ff;
|
||||
border-radius: 999px;
|
||||
color: #0b5ed7;
|
||||
display: inline-flex;
|
||||
font-weight: 700;
|
||||
height: 32px;
|
||||
justify-content: center;
|
||||
min-width: 32px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.task-overview {
|
||||
border-top: 3px solid #0d6efd;
|
||||
}
|
||||
|
||||
.hotspot-item {
|
||||
border-color: #e3eaf2;
|
||||
margin-bottom: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-panel {
|
||||
@@ -98,4 +194,10 @@ body {
|
||||
.hero-copy h1 {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.section-heading,
|
||||
.page-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,46 +111,3 @@ function pollTaskListStatus() {
|
||||
};
|
||||
window.setTimeout(poll, intervalMs);
|
||||
}
|
||||
|
||||
async function downloadExport(url, defaultFilename, event) {
|
||||
if (event) event.preventDefault();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
<script src="/static/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary border-bottom">
|
||||
<nav class="navbar navbar-expand-lg app-navbar">
|
||||
<div class="container">
|
||||
<a class="navbar-brand" href="/">热榜评论分析工具</a>
|
||||
<a class="navbar-brand fw-semibold" href="/">热榜评论雷达</a>
|
||||
<div class="navbar-nav">
|
||||
<a class="nav-link" href="/">任务列表</a>
|
||||
</div>
|
||||
@@ -28,8 +28,5 @@
|
||||
{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="border-top py-3">
|
||||
<div class="container text-muted small">内部演示工具 | 仅供学习参考</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,26 +3,13 @@
|
||||
{% block breadcrumbs %}
|
||||
<nav aria-label="breadcrumb"><ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">首页</a></li>
|
||||
<li class="breadcrumb-item"><a href="/tasks/{{ task.id }}">任务 #{{ task.id }}</a></li>
|
||||
<li class="breadcrumb-item"><a href="/tasks/{{ task.id }}">任务 {{ task.display_id }}</a></li>
|
||||
<li class="breadcrumb-item active">汇总报告</li>
|
||||
</ol></nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h3 mb-0">{{ hotspot.title }} 汇总报告</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<button
|
||||
class="btn btn-outline-primary"
|
||||
onclick="downloadExport('/api/export/hotspots/{{ hotspot.id }}.md', 'hotspot-report.md', event)"
|
||||
{% if not report %}disabled title="报告尚未生成"{% endif %}>
|
||||
导出 Markdown 报告
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="downloadExport('/api/export/hotspots/{{ hotspot.id }}/comments.csv', 'hotspot-comments.csv', event)">
|
||||
导出热点评论 CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if report %}
|
||||
{% include "partials/report_panel.html" %}
|
||||
@@ -30,7 +17,6 @@
|
||||
<div class="text-center text-muted py-5">
|
||||
<div class="spinner-border text-warning mb-3" role="status"></div>
|
||||
<p>报告生成中,请稍候...</p>
|
||||
<p class="small mb-0">Markdown 报告将在分析完成后开放下载;评论 CSV 可先导出已抓取的数据。</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
+16
-11
@@ -8,14 +8,16 @@
|
||||
<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" id="create-task">
|
||||
<div class="card-header">创建抓取任务</div>
|
||||
<section class="tool-card mb-4" id="create-task">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">采集配置</p>
|
||||
<h2>创建抓取任务</h2>
|
||||
</div>
|
||||
<span class="text-muted small">默认规模 5 × 5 × 50</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="form-error" class="alert alert-danger d-none" role="alert"></div>
|
||||
<form id="task-form" onsubmit="submitTask(event)">
|
||||
@@ -50,13 +52,16 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<section class="tool-card" id="task-history">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<p class="section-kicker">任务历史</p>
|
||||
<h2>最近任务</h2>
|
||||
</div>
|
||||
{% if has_running_tasks %}<span class="badge text-bg-warning">自动更新中</span>{% endif %}
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<table class="table app-table align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务 ID</th>
|
||||
|
||||
@@ -3,27 +3,14 @@
|
||||
{% block breadcrumbs %}
|
||||
<nav aria-label="breadcrumb"><ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">首页</a></li>
|
||||
<li class="breadcrumb-item"><a href="/tasks/{{ task.id }}">任务 #{{ task.id }}</a></li>
|
||||
<li class="breadcrumb-item"><a href="/hotspots/{{ hotspot.id }}/report">热点 #{{ hotspot.rank }}:{{ hotspot.title }}</a></li>
|
||||
<li class="breadcrumb-item"><a href="/tasks/{{ task.id }}">任务 {{ task.display_id }}</a></li>
|
||||
<li class="breadcrumb-item"><a href="/hotspots/{{ hotspot.id }}/report">热点 {{ hotspot.rank }}:{{ hotspot.title }}</a></li>
|
||||
<li class="breadcrumb-item active">{{ item.title or item.source_item_id }}</li>
|
||||
</ol></nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h3 mb-0">{{ item.title or item.source_item_id }}</h1>
|
||||
<div class="d-flex gap-2">
|
||||
<button
|
||||
class="btn btn-outline-primary"
|
||||
onclick="downloadExport('/api/export/items/{{ item.id }}.md', 'item-report.md', event)"
|
||||
{% if not report %}disabled title="报告尚未生成"{% endif %}>
|
||||
导出 Markdown 报告
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-outline-secondary"
|
||||
onclick="downloadExport('/api/export/items/{{ item.id }}/comments.csv', 'item-comments.csv', event)">
|
||||
导出评论 CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if report %}
|
||||
{% include "partials/report_panel.html" %}
|
||||
@@ -31,7 +18,6 @@
|
||||
<div class="text-center text-muted py-5">
|
||||
<div class="spinner-border text-warning mb-3" role="status"></div>
|
||||
<p>报告生成中,请稍候...</p>
|
||||
<p class="small mb-0">Markdown 报告将在分析完成后开放下载;评论 CSV 可先导出已抓取的数据。</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
<section class="card"><div class="card-header">评论明细</div><div class="table-responsive">
|
||||
|
||||
@@ -4,14 +4,11 @@
|
||||
{% set ai_percent = rate_percent(task.analysis_success_rate) %}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ task.id }}</code>
|
||||
{% if task.is_demo %}
|
||||
<br><span class="badge text-bg-info">Demo 数据</span>
|
||||
{% endif %}
|
||||
<span class="task-number">{{ task.display_id or loop.index }}</span>
|
||||
</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>
|
||||
<td>{{ task.created_at_label }}</td>
|
||||
<td><small class="text-muted">{{ task.scale_label }}</small></td>
|
||||
<td class="task-progress-cell">
|
||||
<div class="d-flex justify-content-between align-items-center gap-2">
|
||||
<span class="small">已处理 {{ task.processed_items_count }} / 共 {{ task.total_items_count }} 条内容</span>
|
||||
@@ -32,14 +29,11 @@
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {{ status_class }}">{{ status_label }}</span>
|
||||
{% if task.error_stage or task.error_type %}
|
||||
<br><small class="text-muted">{{ task.error_stage }} / {{ task.error_type }}</small>
|
||||
{% endif %}
|
||||
{% if task.error_message %}
|
||||
<br><small class="text-danger">{{ task.error_message }}</small>
|
||||
{% if task.error_summary %}
|
||||
<br><small class="text-danger">{{ task.error_summary }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><a class="btn btn-sm btn-outline-primary" href="/tasks/{{ task.id }}">查看</a></td>
|
||||
<td><a class="btn btn-sm btn-primary" href="/tasks/{{ task.id }}">查看</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}任务 #{{ task.id }} - 热榜评论分析工具{% endblock %}
|
||||
{% block title %}任务 {{ task.display_id }} - 热榜评论分析工具{% endblock %}
|
||||
{% block breadcrumbs %}
|
||||
<nav aria-label="breadcrumb"><ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/">首页</a></li>
|
||||
<li class="breadcrumb-item active">任务 #{{ task.id }}</li>
|
||||
<li class="breadcrumb-item active">任务 {{ task.display_id }}</li>
|
||||
</ol></nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h1 class="h3 mb-0">任务 #{{ task.id }}</h1>
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="window.location.reload()">手动刷新</button>
|
||||
</div>
|
||||
<section class="card mb-4" {% if task.status == "running" %}data-task-id="{{ task.id }}" data-auto-poll="true"{% endif %}><div class="card-body">
|
||||
<div class="page-heading mb-3">
|
||||
<div>
|
||||
<p class="section-kicker">任务详情</p>
|
||||
<h1>任务 {{ task.display_id }}</h1>
|
||||
</div>
|
||||
{% set label, cls = status_badge_config(task.status) %}
|
||||
<span class="badge {{ cls }}">{{ label }}</span>
|
||||
</div>
|
||||
<section class="tool-card task-overview mb-4" {% if task.status == "running" %}data-task-id="{{ task.id }}" data-auto-poll="true"{% endif %}><div class="card-body">
|
||||
{% set percent = progress_percent(task.processed_items_count, task.total_items_count) %}
|
||||
{% set ai_percent = rate_percent(task.analysis_success_rate) %}
|
||||
{% 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>
|
||||
<p class="mb-0">平台:{{ task.platform | platform_label }}</p>
|
||||
<p class="mb-0 text-muted">创建时间 {{ task.created_at_label }} · 阶段 {{ task.current_stage_label or "等待启动" }}</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center gap-2">
|
||||
@@ -72,17 +75,17 @@
|
||||
<span class="badge text-bg-warning ms-1">AI 样本不足</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if task.error_message %}<div class="alert alert-danger">{{ task.error_stage }} / {{ task.error_type }}:{{ task.error_message }}</div>{% endif %}
|
||||
{% if task.error_summary %}<div class="alert alert-danger">{{ task.error_summary }}</div>{% endif %}
|
||||
</div></section>
|
||||
{% if task.status == "running" and not hotspots %}
|
||||
<div class="text-center text-muted py-5"><div class="spinner-border text-warning mb-3"></div><p>正在抓取热点数据,请稍候...</p></div>
|
||||
{% endif %}
|
||||
<div class="accordion" id="hotspot-list">
|
||||
{% for hotspot in hotspots %}
|
||||
<div class="accordion-item">
|
||||
<div class="accordion-item hotspot-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button {% if not loop.first %}collapsed{% endif %}" type="button" data-bs-toggle="collapse" data-bs-target="#hotspot-{{ hotspot.id }}">
|
||||
热点 #{{ hotspot.rank }}:{{ hotspot.title }}
|
||||
热点 {{ hotspot.rank }}:{{ hotspot.title }}
|
||||
</button>
|
||||
</h2>
|
||||
<div id="hotspot-{{ hotspot.id }}" class="accordion-collapse collapse {% if loop.first %}show{% endif %}" data-bs-parent="#hotspot-list">
|
||||
|
||||
+14
-2
@@ -99,9 +99,21 @@ docker compose up -d --build
|
||||
curl -f http://localhost:8000/health
|
||||
```
|
||||
|
||||
## 公网云服务器部署
|
||||
## 公网部署决策清单
|
||||
|
||||
第一版公网演示使用云服务器 + Docker Compose,不增加登录或密码。
|
||||
上线前必须确认以下决策,未经确认不要直接开放公网:
|
||||
|
||||
1. 部署方式:云服务器 Docker Compose、PaaS 平台,还是临时内网穿透演示。
|
||||
2. 访问控制:是否需要访问密码 / 简单登录,或只在可信网络内演示。
|
||||
3. 成本控制:公网用户是否允许直接消耗真实 TikHub 和 AI Key。
|
||||
4. Demo 数据:公网环境使用脱敏 Demo 数据,还是允许展示真实抓取结果。
|
||||
5. 数据生命周期:SQLite 数据是否需要持久保留,以及如何备份 / 重置。
|
||||
|
||||
当前建议:在上述问题确认前,只做本地 Docker 验收和部署准备,不把端口直接暴露到公网。
|
||||
|
||||
## 公网云服务器部署候选方案
|
||||
|
||||
如果确认采用云服务器 Docker Compose,可按以下步骤执行。
|
||||
|
||||
上线前准备:
|
||||
|
||||
|
||||
@@ -1269,6 +1269,35 @@ chore: 增加公网部署配置
|
||||
- 是否允许公网用户直接消耗真实 TikHub 和 AI Key。
|
||||
- 是否需要限制同一时间只能运行一个任务。
|
||||
|
||||
当前推进记录:
|
||||
|
||||
- 日期:2026-07-03
|
||||
- 相关改动:
|
||||
- `docs/Deployment.md` 新增公网部署决策清单,明确上线前必须确认部署方式、访问密码、真实 TikHub / AI Key 消耗、Demo 数据和数据生命周期。
|
||||
- 将“默认云服务器 Docker Compose 且不加密码”的表述改为候选方案,避免未经确认直接开放公网。
|
||||
- `docs/UserGuide.md` 补充公网云服务器候选部署步骤、Demo 数据初始化和数据库异常备份路径。
|
||||
- 补充文档测试,锁定部署文档必须包含上线前确认项。
|
||||
- 验证命令:
|
||||
- `docker exec hot-comments-tool-app-1 sh -lc 'python -m pytest /app/tests/unit/test_docs.py -q'`
|
||||
- `curl -f http://localhost:8000/health`
|
||||
- `curl -s -o /tmp/wo15-index.html -w '%{http_code}' http://localhost:8000/`
|
||||
- `docker compose ps`
|
||||
- `docker compose up -d --build`
|
||||
- 验证结果:
|
||||
- 文档测试:`2 passed`
|
||||
- 本地 health:返回 `{"status":"ok"}`
|
||||
- 首页:HTTP 200
|
||||
- 当前运行容器:`hot-comments-tool-app-1`,端口 `0.0.0.0:8000->8000/tcp`
|
||||
- 镜像重建:仍卡在 `pip install uv` 下载阶段,手动中断,未完成新镜像 rebuild。
|
||||
- 当前结论:
|
||||
- 本地服务可访问,部署文档已进入更安全的“待确认后上线”状态。
|
||||
- 未执行公网部署,原因是 WO-15 的关键产品 / 安全决策仍待用户确认,且当前网络环境下镜像重建仍不稳定。
|
||||
- 后续必须确认:
|
||||
- 部署方式:云服务器 Docker Compose / PaaS / 临时内网穿透。
|
||||
- 是否需要访问密码 / 简单登录。
|
||||
- 是否允许公网访问者直接创建真实抓取任务并消耗 TikHub / AI Key。
|
||||
- 是否先完成可复现 Demo 数据方案(WO-14)再公网演示。
|
||||
|
||||
### WO-16 UI 信息简化与产品化文案清理
|
||||
|
||||
优先级:P1
|
||||
@@ -1325,6 +1354,29 @@ feat: 优化 MVP 页面产品化体验
|
||||
- UI 风格方向:更偏数据仪表盘、内部工具,还是偏演示型产品页面。
|
||||
- 是否需要提供简单品牌名 / Logo / 说明文案。
|
||||
|
||||
完成记录:
|
||||
|
||||
- 完成日期:2026-07-03
|
||||
- 相关改动:
|
||||
- 任务列表和任务详情默认展示顺序号 `#1/#2/#3`,不再把完整 UUID 铺在列表和标题中。
|
||||
- 任务详情页保留“完整 ID”排查信息,方便复制给开发定位。
|
||||
- 创建时间压缩为 `YYYY-MM-DD HH:mm`,配置规模压缩为 `小红书 · 5热点 × 5内容 × 50评论`。
|
||||
- 失败状态只展示中文错误说明,不再直接展示 `system / unexpected_restart`、`recover / interrupted` 等内部枚举。
|
||||
- 移除页脚 `内部演示工具 | 仅供学习参考`。
|
||||
- 验证命令:
|
||||
- `docker cp app/. hot-comments-tool-app-1:/app/app/`
|
||||
- `docker cp tests/. hot-comments-tool-app-1:/app/tests/`
|
||||
- `docker exec hot-comments-tool-app-1 sh -lc 'python -m pytest /app/tests/integration/test_routes.py::test_index_page_uses_short_task_numbers_compact_fields_and_friendly_error_copy /app/tests/integration/test_routes.py::test_task_detail_uses_short_number_title_and_keeps_full_uuid_for_diagnostics -q'`
|
||||
- `docker exec hot-comments-tool-app-1 sh -lc 'python -m pytest /app/tests/integration/test_routes.py -q'`
|
||||
- 验证结果:
|
||||
- WO-16 聚焦测试:`2 passed, 1 warning`
|
||||
- 路由集成测试:`23 passed, 1 warning`
|
||||
- 验收结论:
|
||||
- 任务列表、任务详情、报告页和内容详情页的任务引用已改为短编号。
|
||||
- 用户界面不再展示内部英文错误枚举和演示页脚文案。
|
||||
- 遗留问题:
|
||||
- 本轮不做大规模视觉重设计,品牌名 / Logo 仍需用户确认后再纳入正式工单。
|
||||
|
||||
### WO-17 默认规模数据量解释与展示优化
|
||||
|
||||
优先级:P1
|
||||
|
||||
+31
-9
@@ -93,16 +93,13 @@ http://localhost:8000
|
||||
|
||||
如果报告尚未生成,页面会显示“报告生成中,请稍候...”,不会返回 500。
|
||||
|
||||
## 5. 导出文件
|
||||
## 5. 文件导出
|
||||
|
||||
支持导出:
|
||||
当前版本已关闭 CSV / Markdown 文件下载入口和接口。页面仍可直接查看:
|
||||
|
||||
- 热点报告 Markdown
|
||||
- 内容报告 Markdown
|
||||
- 热点评论 CSV
|
||||
- 内容评论 CSV
|
||||
|
||||
CSV 使用 UTF-8-BOM,适合 Windows Excel 打开。评论内容中的换行会被替换为空格,`= + - @` 开头的内容会自动加单引号,避免被 Excel 当作公式执行。
|
||||
- 热点级汇总报告
|
||||
- 内容条目级报告
|
||||
- 评论明细
|
||||
|
||||
## 6. 小规模验收
|
||||
|
||||
@@ -119,7 +116,7 @@ CSV 使用 UTF-8-BOM,适合 Windows Excel 打开。评论内容中的换行会
|
||||
- 至少有热点、内容、评论、报告。
|
||||
- AI 成功率正常,或页面有明确“AI 样本不足”提示。
|
||||
- 能打开热点报告、内容详情页。
|
||||
- 能导出 Markdown 和 CSV。
|
||||
- 页面不展示 CSV / Markdown 下载入口。
|
||||
|
||||
## 7. 默认规模验收
|
||||
|
||||
@@ -197,6 +194,31 @@ docker compose up -d --build
|
||||
curl -f http://localhost:8000/health
|
||||
```
|
||||
|
||||
### 公网云服务器部署
|
||||
|
||||
公网部署前先确认部署方式、访问密码、是否允许消耗真实 TikHub 和 AI Key。未经确认不要直接开放公网。
|
||||
|
||||
如果确认使用云服务器 Docker Compose,步骤与本地类似:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
cp .env.example .env
|
||||
docker compose up -d --build
|
||||
curl -f http://127.0.0.1:8000/health
|
||||
```
|
||||
|
||||
公网演示建议先初始化脱敏 Demo 数据:
|
||||
|
||||
```bash
|
||||
docker compose exec app python -m app.demo_seed
|
||||
```
|
||||
|
||||
如果页面出现数据库不可用提示,系统会尽量把 SQLite 文件备份到:
|
||||
|
||||
```text
|
||||
data/corrupt-backups
|
||||
```
|
||||
|
||||
## 9. 推荐提交节奏
|
||||
|
||||
按 `docs/MVP-WorkOrders.md` 的工单推进:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
beautifulsoup4>=4.12.3
|
||||
fastapi>=0.115.0
|
||||
httpx>=0.27.0
|
||||
jinja2>=3.1.4
|
||||
pydantic>=2.8.0
|
||||
pydantic-settings>=2.4.0
|
||||
sqlalchemy>=2.0.32
|
||||
uvicorn[standard]>=0.30.6
|
||||
@@ -1,5 +1,7 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.models import Comment, ContentItem, Hotspot, Report, Task
|
||||
from tests.helpers import make_test_client
|
||||
from sqlalchemy.exc import OperationalError
|
||||
@@ -16,7 +18,10 @@ def test_index_page_renders_task_form_empty_state_and_default_scale():
|
||||
assert 'name="item_limit_per_hotspot"' in response.text
|
||||
assert 'name="comment_limit_per_item"' in response.text
|
||||
assert "1250" in response.text
|
||||
assert 'onclick="window.location.href=\'/\'"' in response.text
|
||||
assert "手动刷新" not in response.text
|
||||
assert 'onclick="window.location.href=\'/\'"' not in response.text
|
||||
assert 'href="#create-task"' not in response.text
|
||||
assert 'href="#task-history"' not in response.text
|
||||
assert "还没有任何任务" in response.text
|
||||
|
||||
|
||||
@@ -59,6 +64,62 @@ def test_index_page_lists_existing_tasks():
|
||||
assert "DOMContentLoaded" in response.text
|
||||
|
||||
|
||||
def test_index_page_uses_short_task_numbers_compact_fields_and_friendly_error_copy():
|
||||
first_id = "11111111-1111-4111-8111-111111111111"
|
||||
second_id = "22222222-2222-4222-8222-222222222222"
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
Task(
|
||||
id=first_id,
|
||||
platform="xiaohongshu",
|
||||
status="failed",
|
||||
created_at=datetime(2026, 7, 3, 10, 30, tzinfo=UTC),
|
||||
hotspot_limit=5,
|
||||
item_limit_per_hotspot=5,
|
||||
comment_limit_per_item=50,
|
||||
error_stage="system",
|
||||
error_type="unexpected_restart",
|
||||
error_message="系统重启,任务被中断",
|
||||
)
|
||||
)
|
||||
session.add(
|
||||
Task(
|
||||
id=second_id,
|
||||
platform="douyin",
|
||||
status="success",
|
||||
created_at=datetime(2026, 7, 3, 10, 35, tzinfo=UTC),
|
||||
hotspot_limit=1,
|
||||
item_limit_per_hotspot=1,
|
||||
comment_limit_per_item=10,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
response = client.get("/")
|
||||
|
||||
assert response.status_code == 200
|
||||
visible_text = BeautifulSoup(response.text, "html.parser").get_text(" ")
|
||||
assert " 1 " in f" {visible_text} "
|
||||
assert " 2 " in f" {visible_text} "
|
||||
assert "#" not in visible_text
|
||||
assert first_id not in visible_text
|
||||
assert second_id not in visible_text
|
||||
assert "2026-07-03 18:30" in visible_text
|
||||
assert "2026-07-03 18:35" in visible_text
|
||||
assert "Demo 数据" not in visible_text
|
||||
assert "5热点 × 5内容 × 50评论" in visible_text
|
||||
assert "1热点 × 1内容 × 10评论" in visible_text
|
||||
assert "小红书 · 5热点" not in visible_text
|
||||
assert "抖音 · 1热点" not in visible_text
|
||||
assert "系统重启,任务被中断" in visible_text
|
||||
assert "system / unexpected_restart" not in visible_text
|
||||
assert "unexpected_restart" not in visible_text
|
||||
assert "内部演示工具 | 仅供学习参考" not in visible_text
|
||||
|
||||
|
||||
def test_index_page_does_not_auto_poll_without_running_tasks():
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -112,11 +173,75 @@ def test_running_task_detail_page_auto_polls_current_task():
|
||||
assert response.status_code == 200
|
||||
assert 'data-task-id="task-running"' in response.text
|
||||
assert "pollTaskDetailStatus" in response.text
|
||||
assert 'onclick="window.location.reload()"' in response.text
|
||||
assert "手动刷新" not in response.text
|
||||
assert 'onclick="window.location.reload()"' not in response.text
|
||||
assert "已处理 1 / 共 2 条内容" in response.text
|
||||
assert "AI 成功率 100%" in response.text
|
||||
|
||||
|
||||
def test_task_detail_uses_short_number_title_and_hides_full_uuid():
|
||||
task_id = "33333333-3333-4333-8333-333333333333"
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
Task(
|
||||
id=task_id,
|
||||
platform="xiaohongshu",
|
||||
status="running",
|
||||
created_at=datetime(2026, 7, 3, 10, 30, tzinfo=UTC),
|
||||
hotspot_limit=5,
|
||||
item_limit_per_hotspot=5,
|
||||
comment_limit_per_item=50,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
response = client.get(f"/tasks/{task_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
visible_text = BeautifulSoup(response.text, "html.parser").get_text(" ")
|
||||
assert "<title>任务 1 - 热榜评论分析工具</title>" in response.text
|
||||
assert "任务 1" in visible_text
|
||||
assert "完整 ID" not in visible_text
|
||||
assert task_id not in visible_text
|
||||
assert "任务 #33333333-3333-4333-8333-333333333333" not in visible_text
|
||||
assert "#" not in visible_text
|
||||
assert "创建时间 2026-07-03 18:30" in visible_text
|
||||
assert "Demo 数据" not in visible_text
|
||||
|
||||
|
||||
def test_demo_task_flag_is_kept_but_demo_copy_is_hidden_from_pages():
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
Task(
|
||||
id="demo-task-hidden-copy",
|
||||
platform="xiaohongshu",
|
||||
status="success",
|
||||
created_at=datetime(2026, 7, 3, 10, 30, tzinfo=UTC),
|
||||
hotspot_limit=1,
|
||||
item_limit_per_hotspot=1,
|
||||
comment_limit_per_item=10,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
index_response = client.get("/")
|
||||
detail_response = client.get("/tasks/demo-task-hidden-copy")
|
||||
api_response = client.get("/api/tasks/demo-task-hidden-copy")
|
||||
|
||||
assert index_response.status_code == 200
|
||||
assert detail_response.status_code == 200
|
||||
assert api_response.status_code == 200
|
||||
assert api_response.json()["is_demo"] is True
|
||||
assert "Demo 数据" not in BeautifulSoup(index_response.text, "html.parser").get_text(" ")
|
||||
assert "Demo 数据" not in BeautifulSoup(detail_response.text, "html.parser").get_text(" ")
|
||||
|
||||
|
||||
def test_running_task_detail_page_keeps_polling_after_hotspots_exist():
|
||||
with make_test_client() as (client, engine):
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -209,7 +334,7 @@ def test_running_task_detail_page_shows_stage_runtime_and_stale_progress_warning
|
||||
response = client.get("/tasks/task-stale")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "阶段:AI 分析中" in response.text
|
||||
assert "阶段 AI 分析中" in response.text
|
||||
assert "运行时长" in response.text
|
||||
assert "2小时5分钟" in response.text
|
||||
assert "最近进度" in response.text
|
||||
@@ -241,7 +366,8 @@ def test_failed_task_detail_page_shows_failure_reason_without_loading_copy():
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "失败" in response.text
|
||||
assert "recover / interrupted:系统重启,任务被中断" in response.text
|
||||
assert "系统重启,任务被中断" in response.text
|
||||
assert "recover / interrupted" not in response.text
|
||||
assert "正在抓取热点数据" not in response.text
|
||||
assert "pollTaskDetailStatus" not in response.text
|
||||
|
||||
@@ -330,11 +456,15 @@ def test_result_pages_render_seeded_data():
|
||||
|
||||
assert task_response.status_code == 200
|
||||
assert "热点标题" in task_response.text
|
||||
assert 'onclick="window.location.reload()"' in task_response.text
|
||||
assert "热点 1:热点标题" in BeautifulSoup(task_response.text, "html.parser").get_text(" ")
|
||||
assert 'onclick="window.location.reload()"' not in task_response.text
|
||||
assert "手动刷新" not in task_response.text
|
||||
assert hotspot_response.status_code == 200
|
||||
assert "热点总结" in hotspot_response.text
|
||||
assert "downloadExport('/api/export/hotspots/hot-result.md'" in hotspot_response.text
|
||||
assert "downloadExport('/api/export/hotspots/hot-result/comments.csv'" in hotspot_response.text
|
||||
assert "downloadExport(" not in hotspot_response.text
|
||||
assert "/api/export/" not in hotspot_response.text
|
||||
assert "导出 Markdown" not in hotspot_response.text
|
||||
assert "导出热点评论 CSV" not in hotspot_response.text
|
||||
assert "评论样本" in hotspot_response.text
|
||||
assert "关联内容" in hotspot_response.text
|
||||
assert "正向" in hotspot_response.text
|
||||
@@ -345,8 +475,13 @@ def test_result_pages_render_seeded_data():
|
||||
assert "<pre" not in hotspot_response.text
|
||||
assert item_response.status_code == 200
|
||||
assert "内容总结" in item_response.text
|
||||
assert "downloadExport('/api/export/items/item-result.md'" in item_response.text
|
||||
assert "downloadExport('/api/export/items/item-result/comments.csv'" in item_response.text
|
||||
item_visible_text = BeautifulSoup(item_response.text, "html.parser").get_text(" ")
|
||||
assert "热点 1:热点标题" in item_visible_text
|
||||
assert "#" not in item_visible_text
|
||||
assert "downloadExport(" not in item_response.text
|
||||
assert "/api/export/" not in item_response.text
|
||||
assert "导出 Markdown" not in item_response.text
|
||||
assert "导出评论 CSV" not in item_response.text
|
||||
assert "评论样本" in item_response.text
|
||||
assert "Top 标签" in item_response.text
|
||||
assert "评论内容" in item_response.text
|
||||
@@ -381,14 +516,16 @@ def test_report_pages_render_empty_state_when_report_missing():
|
||||
|
||||
assert hotspot_response.status_code == 200
|
||||
assert "报告生成中,请稍候" in hotspot_response.text
|
||||
assert 'title="报告尚未生成"' in hotspot_response.text
|
||||
assert "Markdown 报告将在分析完成后开放下载" in hotspot_response.text
|
||||
assert "downloadExport('/api/export/hotspots/hot-no-report/comments.csv'" in hotspot_response.text
|
||||
assert 'title="报告尚未生成"' not in hotspot_response.text
|
||||
assert "Markdown 报告将在分析完成后开放下载" not in hotspot_response.text
|
||||
assert "downloadExport(" not in hotspot_response.text
|
||||
assert "/api/export/" not in hotspot_response.text
|
||||
assert item_response.status_code == 200
|
||||
assert "报告生成中,请稍候" in item_response.text
|
||||
assert 'title="报告尚未生成"' in item_response.text
|
||||
assert "Markdown 报告将在分析完成后开放下载" in item_response.text
|
||||
assert "downloadExport('/api/export/items/item-no-report/comments.csv'" in item_response.text
|
||||
assert 'title="报告尚未生成"' not in item_response.text
|
||||
assert "Markdown 报告将在分析完成后开放下载" not in item_response.text
|
||||
assert "downloadExport(" not in item_response.text
|
||||
assert "/api/export/" not in item_response.text
|
||||
|
||||
|
||||
def test_report_pages_warn_when_ai_sample_is_insufficient():
|
||||
@@ -411,7 +548,7 @@ def test_report_pages_warn_when_ai_sample_is_insufficient():
|
||||
assert "当前有效评论样本不足" in item_response.text
|
||||
|
||||
|
||||
def test_export_routes_return_csv_and_markdown():
|
||||
def test_export_routes_are_removed():
|
||||
with make_test_client() as (client, engine):
|
||||
seed_result_data(engine)
|
||||
|
||||
@@ -420,15 +557,10 @@ def test_export_routes_return_csv_and_markdown():
|
||||
hotspot_md = client.get("/api/export/hotspots/hot-result.md")
|
||||
item_md = client.get("/api/export/items/item-result.md")
|
||||
|
||||
assert csv_response.status_code == 200
|
||||
assert csv_response.content.startswith(b"\xef\xbb\xbf")
|
||||
assert "评论内容".encode("utf-8") in csv_response.content
|
||||
assert hotspot_csv_response.status_code == 200
|
||||
assert "评论内容".encode("utf-8") in hotspot_csv_response.content
|
||||
assert hotspot_md.status_code == 200
|
||||
assert "热点总结" in hotspot_md.text
|
||||
assert item_md.status_code == 200
|
||||
assert "内容总结" in item_md.text
|
||||
assert csv_response.status_code == 404
|
||||
assert hotspot_csv_response.status_code == 404
|
||||
assert hotspot_md.status_code == 404
|
||||
assert item_md.status_code == 404
|
||||
|
||||
|
||||
def test_api_tasks_returns_service_unavailable_when_database_has_io_error(monkeypatch):
|
||||
@@ -524,18 +656,6 @@ def test_item_detail_page_shows_database_error_state_when_database_has_io_error(
|
||||
assert "数据库暂时不可用" in response.text
|
||||
|
||||
|
||||
def test_export_route_returns_service_unavailable_when_database_has_io_error(monkeypatch):
|
||||
def broken_export(_session, _item_id):
|
||||
raise OperationalError("SELECT 1", {}, Exception("disk I/O error"))
|
||||
|
||||
monkeypatch.setattr("app.main.export_item_comments_csv", broken_export)
|
||||
|
||||
with make_test_client() as (client, _engine):
|
||||
response = client.get("/api/export/items/item-io/comments.csv")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert response.json()["detail"] == "数据库暂时不可用,请稍后重试或联系维护者恢复数据。"
|
||||
|
||||
|
||||
def test_missing_html_pages_render_friendly_not_found_page():
|
||||
with make_test_client() as (client, _engine):
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db import Base, get_db_session
|
||||
@@ -92,6 +95,47 @@ def test_create_task_returns_400_when_running_task_exists():
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_create_task_recovers_stale_running_task_before_creating_new_one(monkeypatch):
|
||||
client, engine = make_test_client()
|
||||
now = datetime(2026, 7, 3, 12, 0, tzinfo=UTC)
|
||||
monkeypatch.setattr("app.services.task_service.utc_now", lambda: now)
|
||||
monkeypatch.setattr("app.services.task_service.task_executor.submit", lambda *_args, **_kwargs: None)
|
||||
try:
|
||||
with Session(engine) as session:
|
||||
session.add(
|
||||
Task(
|
||||
id="stale-running",
|
||||
platform="douyin",
|
||||
status="running",
|
||||
current_stage="ai_analysis",
|
||||
last_progress_at=now - timedelta(minutes=11),
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
response = client.post(
|
||||
"/api/tasks",
|
||||
json={
|
||||
"platform": "douyin",
|
||||
"hotspot_limit": 5,
|
||||
"item_limit_per_hotspot": 5,
|
||||
"comment_limit_per_item": 50,
|
||||
},
|
||||
)
|
||||
|
||||
with Session(engine) as session:
|
||||
stale_task = session.get(Task, "stale-running")
|
||||
|
||||
assert response.status_code == 201
|
||||
assert stale_task.status == "failed"
|
||||
assert stale_task.error_stage == "system"
|
||||
assert stale_task.error_type == "stale_progress_timeout"
|
||||
assert "超过 10 分钟没有进度更新" in stale_task.error_message
|
||||
finally:
|
||||
engine.dispose()
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_task_list_and_detail_return_created_tasks():
|
||||
client, engine = make_test_client()
|
||||
try:
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import json
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -147,3 +149,43 @@ def test_xiaohongshu_task_flow_keeps_item_success_when_ai_request_raises(monkeyp
|
||||
assert persisted.analysis_success_rate == 0.0
|
||||
assert comment.ai_analysis_status == "failed"
|
||||
assert comment.reason == "ai_parse_failed"
|
||||
|
||||
|
||||
def test_xiaohongshu_task_flow_times_out_stalled_ai_request(monkeypatch):
|
||||
with make_test_client() as (_client, engine):
|
||||
monkeypatch.setattr("app.services.task_service.build_platform", lambda _platform: FakeXhsPlatform())
|
||||
monkeypatch.setattr(
|
||||
"app.services.task_service.get_settings",
|
||||
lambda: SimpleNamespace(ai_max_retries=1, ai_timeout_seconds=0.01),
|
||||
)
|
||||
|
||||
def stalled_requester(prompt):
|
||||
time.sleep(0.2)
|
||||
comments = json.loads(prompt[prompt.index("[") :])
|
||||
return json.dumps(
|
||||
[{"comment_id": comments[0]["comment_id"], "sentiment": "positive", "labels": ["超时后不应采用"], "reason": "late"}],
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("app.services.task_service.build_ai_dependencies", lambda: (stalled_requester, None))
|
||||
|
||||
with Session(engine) as session:
|
||||
task = create_task(
|
||||
session,
|
||||
CreateTaskRequest(platform="xiaohongshu", hotspot_limit=1, item_limit_per_hotspot=1, comment_limit_per_item=10),
|
||||
submit_background=False,
|
||||
)
|
||||
task_id = task.id
|
||||
|
||||
with Session(engine) as session:
|
||||
run_task(task_id, session_factory=lambda: session)
|
||||
|
||||
persisted = session.get(Task, task_id)
|
||||
comment = session.scalar(select(Comment).where(Comment.task_id == task_id))
|
||||
|
||||
assert persisted.status == "success"
|
||||
assert persisted.finished_at is not None
|
||||
assert persisted.analysis_status == "insufficient"
|
||||
assert comment.ai_analysis_status == "failed"
|
||||
assert comment.sentiment == "unknown"
|
||||
assert comment.reason == "ai_parse_failed"
|
||||
|
||||
@@ -51,3 +51,17 @@ def test_tikhub_client_raises_structured_error_after_retries(monkeypatch):
|
||||
assert exc_info.value.status_code == 429
|
||||
assert "secret-token" not in str(exc_info.value)
|
||||
assert sleeps == [1, 2, 4]
|
||||
|
||||
|
||||
def test_tikhub_client_reports_401_as_auth_error_without_leaking_token():
|
||||
transport = SequenceTransport([httpx.Response(401, json={"message": "Unauthorized"})])
|
||||
http_client = httpx.Client(transport=httpx.MockTransport(transport))
|
||||
client = TikHubClient(base_url="https://api.test", api_key="secret-token", http_client=http_client)
|
||||
|
||||
with pytest.raises(PlatformAPIError) as exc_info:
|
||||
client.get("/demo")
|
||||
|
||||
assert exc_info.value.error_type == "auth_error"
|
||||
assert exc_info.value.status_code == 401
|
||||
assert str(exc_info.value) == "TikHub 鉴权失败,请检查 TIKHUB_API_KEY 是否有效"
|
||||
assert "secret-token" not in str(exc_info.value)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import pytest
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app.db import check_database_integrity, checkpoint_sqlite_wal, create_sqlite_engine, ensure_sqlite_schema_compat
|
||||
|
||||
@@ -11,6 +12,14 @@ def test_check_database_integrity_returns_ok_for_valid_sqlite_database():
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_file_sqlite_engine_does_not_reuse_connections_after_operational_errors(tmp_path):
|
||||
engine = create_sqlite_engine(f"sqlite:///{tmp_path / 'app.db'}")
|
||||
try:
|
||||
assert isinstance(engine.pool, NullPool)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_check_database_integrity_raises_when_sqlite_reports_problem(monkeypatch):
|
||||
class FakeCursor:
|
||||
def execute(self, _sql):
|
||||
|
||||
@@ -29,3 +29,19 @@ def test_real_env_file_is_gitignored():
|
||||
gitignore_lines = (ROOT / ".gitignore").read_text(encoding="utf-8").splitlines()
|
||||
|
||||
assert ".env" in gitignore_lines
|
||||
|
||||
|
||||
def test_dockerfile_does_not_bootstrap_uv_from_runtime_pip():
|
||||
dockerfile_content = (ROOT / "Dockerfile").read_text(encoding="utf-8")
|
||||
|
||||
assert "pip install --no-cache-dir uv" not in dockerfile_content
|
||||
assert "uv pip install" not in dockerfile_content
|
||||
|
||||
|
||||
def test_dockerfile_installs_project_without_build_isolation():
|
||||
dockerfile_content = (ROOT / "Dockerfile").read_text(encoding="utf-8")
|
||||
|
||||
assert "COPY requirements.txt ./" in dockerfile_content
|
||||
assert "pip install --no-cache-dir -r requirements.txt" in dockerfile_content
|
||||
assert "pip install --no-cache-dir ." not in dockerfile_content
|
||||
assert "pip install --no-cache-dir --no-build-isolation ." not in dockerfile_content
|
||||
|
||||
+19
-2
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_user_guide_covers_startup_acceptance_exports_and_troubleshooting():
|
||||
def test_user_guide_covers_startup_acceptance_no_exports_and_troubleshooting():
|
||||
content = (ROOT / "docs" / "UserGuide.md").read_text(encoding="utf-8")
|
||||
|
||||
for required in [
|
||||
@@ -12,7 +12,9 @@ def test_user_guide_covers_startup_acceptance_exports_and_troubleshooting():
|
||||
"http://localhost:8000",
|
||||
"1 × 1 × 10",
|
||||
"5 × 5 × 50",
|
||||
"导出文件",
|
||||
"文件导出",
|
||||
"已关闭 CSV / Markdown 文件下载入口和接口",
|
||||
"页面不展示 CSV / Markdown 下载入口",
|
||||
"8000 端口被占用",
|
||||
"API Key 缺失",
|
||||
"任务长期 running",
|
||||
@@ -22,3 +24,18 @@ def test_user_guide_covers_startup_acceptance_exports_and_troubleshooting():
|
||||
"data/corrupt-backups",
|
||||
]:
|
||||
assert required in content
|
||||
|
||||
|
||||
def test_deployment_doc_requires_public_access_decisions_before_going_online():
|
||||
content = (ROOT / "docs" / "Deployment.md").read_text(encoding="utf-8")
|
||||
|
||||
for required in [
|
||||
"上线前必须确认",
|
||||
"部署方式",
|
||||
"访问密码",
|
||||
"真实 TikHub 和 AI Key",
|
||||
"未经确认不要直接开放公网",
|
||||
]:
|
||||
assert required in content
|
||||
|
||||
assert "第一版公网演示使用云服务器 + Docker Compose,不增加登录或密码" not in content
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
from app.services.export_service import labels_to_text, safe_csv_field, safe_filename
|
||||
|
||||
|
||||
def test_labels_to_text_joins_json_array_with_chinese_comma():
|
||||
assert labels_to_text('["认可", "质量好"]') == "认可,质量好"
|
||||
assert labels_to_text("bad-json") == ""
|
||||
|
||||
|
||||
def test_safe_csv_field_prevents_formula_injection_and_newlines():
|
||||
assert safe_csv_field("=1+1") == "'=1+1"
|
||||
assert safe_csv_field("+1+1") == "'+1+1"
|
||||
assert safe_csv_field("-1+1") == "'-1+1"
|
||||
assert safe_csv_field("@SUM(1,1)") == "'@SUM(1,1)"
|
||||
assert safe_csv_field("hello\nworld") == "hello world"
|
||||
assert safe_csv_field("hello\r\nworld") == "hello world"
|
||||
assert safe_csv_field("hello\rworld") == "hello world"
|
||||
|
||||
|
||||
def test_safe_filename_replaces_illegal_chars_and_truncates_keyword():
|
||||
filename = safe_filename("douyin", "task", "a/b:c*d?e<f>g|很长很长很长很长很长")
|
||||
|
||||
assert "/" not in filename
|
||||
assert "__" not in filename
|
||||
assert filename.endswith(".csv")
|
||||
Reference in New Issue
Block a user