feat: schedule periodic reports asynchronously

This commit is contained in:
2026-07-31 17:11:21 +08:00
parent a589a25bdc
commit 0008903e8d
21 changed files with 969 additions and 145 deletions

View File

@@ -58,6 +58,28 @@ def test_default_entitlement_uses_basic_plan_when_user_has_no_assignment():
assert view.monthly_topic_remaining == 27
def test_legacy_teacher_plan_is_not_exposed_or_used_as_default():
with _db() as db:
user, _session = _seed_user_session(db)
db.add(
EntitlementPlan(
id=10,
name="旧老师工作版",
plan_type="teacher",
monthly_topic_limit=None,
status=1,
sort_order=1,
)
)
db.commit()
plans = EntitlementService.list_plans(db, include_disabled=True)
view = EntitlementService.active_entitlement(db, user)
assert plans == []
assert view.plan_type == "legacy"
def test_assign_user_plan_replaces_previous_active_plan():
with _db() as db:
user, _session = _seed_user_session(db)

View File

@@ -2,16 +2,19 @@ from __future__ import annotations
from datetime import UTC, datetime, timedelta
from sqlalchemy import create_engine
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.models import Base
from app.models.ai_config import SystemConfig
from app.models.chat import ChatSession, TopicSession
from app.models.entitlement import EntitlementPlan, UserEntitlement
from app.models.growth import PeriodicReport, TopicSummary, UserGrowthProfile
from app.models.user import User
from app.services.model_service import ModelClientService
from app.services.periodic_report_service import PeriodicReportService, periodic_report_dict
from app.services.periodic_report_worker import PeriodicReportWorker, scheduled_period
def _db() -> Session:
@@ -66,3 +69,175 @@ def test_generate_empty_periodic_report_when_no_summaries():
assert report.status == "empty"
assert "还没有可用于生成报告的主题沉淀" in report.content
assert periodic_report_dict(report)["sourceSummaryIds"] == []
def test_async_report_job_is_durable_and_idempotent():
with _db() as db:
now = _now()
db.add(SystemConfig(config_key="mock_model_enabled", config_value="true"))
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
session = ChatSession(id=1, user_id=1, title="本周主题", message_count=2, last_message_at=now, is_deleted=0)
topic = TopicSession(id=1, user_id=1, chat_session_id=1, title="本周主题", core_question="我该怎么观察", status="completed")
summary = TopicSummary(
id=1,
user_id=1,
topic_session_id=1,
summary="本周看见了身体紧张。",
generated_at=now - timedelta(days=1),
)
db.add_all([user, session, topic, summary])
db.commit()
period_start = now - timedelta(days=7)
report = PeriodicReportService.enqueue_for_user(
db,
user=user,
report_type="weekly",
period_start=period_start,
period_end=now,
generated_by="admin:1",
)
db.commit()
duplicate = PeriodicReportService.enqueue_for_user(
db,
user=user,
report_type="weekly",
period_start=period_start,
period_end=now,
generated_by="admin:1",
)
db.commit()
assert duplicate.id == report.id
assert db.query(PeriodicReport).count() == 1
assert duplicate.status == "pending"
report_id = PeriodicReportWorker.claim_next(db, worker_id="test-worker", now=_now() + timedelta(seconds=1))
assert report_id == report.id
running = db.get(PeriodicReport, report.id)
assert running is not None
assert running.status == "running"
assert running.attempt_count == 1
completed = PeriodicReportWorker.execute_claimed(db, report_id=report.id, worker_id="test-worker")
assert completed is not None
assert completed.status == "success"
assert completed.finished_at is not None
assert completed.locked_by is None
def test_failed_async_report_is_retried(monkeypatch):
with _db() as db:
now = _now()
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
session = ChatSession(id=1, user_id=1, title="本周主题", message_count=2, last_message_at=now, is_deleted=0)
topic = TopicSession(id=1, user_id=1, chat_session_id=1, title="本周主题", core_question="我该怎么观察", status="completed")
summary = TopicSummary(
id=1,
user_id=1,
topic_session_id=1,
summary="本周看见了身体紧张。",
generated_at=now - timedelta(days=1),
)
db.add_all([user, session, topic, summary])
db.commit()
report = PeriodicReportService.enqueue_for_user(
db,
user=user,
report_type="weekly",
period_start=now - timedelta(days=7),
period_end=now,
)
db.commit()
monkeypatch.setattr(
ModelClientService,
"generate_text_or_raise",
staticmethod(lambda _db, _prompt: (_ for _ in ()).throw(RuntimeError("模型暂时不可用"))),
)
report_id = PeriodicReportWorker.claim_next(db, worker_id="retry-worker", now=_now() + timedelta(seconds=1))
result = PeriodicReportWorker.execute_claimed(db, report_id=report_id, worker_id="retry-worker")
assert result is not None
assert result.status == "pending"
assert result.attempt_count == 1
assert result.next_run_at is not None
assert "模型暂时不可用" in (result.error_message or "")
def test_schedule_enqueues_only_users_with_enabled_report_entitlement():
with _db() as db:
now = datetime(2026, 8, 2, 19, 0, 0) # 上海时间 2026-08-03 周一 03:00
plan = EntitlementPlan(
id=1,
name="深度陪伴版",
plan_type="deep",
enable_periodic_reports=1,
status=1,
)
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
session = ChatSession(id=1, user_id=1, title="本周主题", message_count=2, last_message_at=now, is_deleted=0)
topic = TopicSession(id=1, user_id=1, chat_session_id=1, title="本周主题", core_question="本周问题", status="completed")
summary = TopicSummary(
id=1,
user_id=1,
topic_session_id=1,
summary="本周主题沉淀",
generated_at=datetime(2026, 8, 1, 8, 0, 0),
)
entitlement = UserEntitlement(id=1, user_id=1, plan_id=1, status="active")
db.add_all([plan, user, session, topic, summary, entitlement])
db.commit()
first = PeriodicReportWorker.enqueue_due_schedules(db, now_utc=now)
db.commit()
second = PeriodicReportWorker.enqueue_due_schedules(db, now_utc=now)
db.commit()
report = db.scalar(select(PeriodicReport).where(PeriodicReport.report_type == "weekly"))
assert first["weekly"] == 1
assert second == {}
assert report is not None
assert report.status == "pending"
assert report.generated_by == "schedule:weekly"
def test_scheduled_period_uses_shanghai_calendar_boundaries():
weekly = scheduled_period("weekly", datetime(2026, 8, 2, 19, 0, 0))
monthly = scheduled_period("monthly", datetime(2026, 7, 31, 19, 0, 0))
manual_weekly_a = PeriodicReportService.default_period("weekly", datetime(2026, 7, 29, 2, 0, 0))
manual_weekly_b = PeriodicReportService.default_period("weekly", datetime(2026, 7, 31, 2, 0, 0))
assert weekly == (datetime(2026, 7, 26, 16, 0, 0), datetime(2026, 8, 2, 16, 0, 0))
assert monthly == (datetime(2026, 6, 30, 16, 0, 0), datetime(2026, 7, 31, 16, 0, 0))
assert manual_weekly_a == manual_weekly_b
def test_stale_running_job_is_recovered_after_restart():
with _db() as db:
now = _now()
user = User(id=1, phone="13800000001", name="学员", daily_chat_limit=100, daily_chat_used=0)
db.add(user)
db.commit()
report = PeriodicReportService.enqueue_for_user(
db,
user=user,
report_type="weekly",
period_start=now - timedelta(days=7),
period_end=now,
)
report.status = "running"
report.attempt_count = 1
report.locked_by = "stopped-worker"
report.locked_at = now - timedelta(minutes=31)
db.commit()
recovered = PeriodicReportWorker.recover_stale_jobs(db, now=now)
db.commit()
db.refresh(report)
assert recovered == 1
assert report.status == "pending"
assert report.locked_by is None
assert report.next_run_at == now
assert "自动恢复" in (report.error_message or "")