feat: schedule periodic reports asynchronously
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 "")
|
||||
|
||||
Reference in New Issue
Block a user