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

@@ -98,7 +98,7 @@ const studentImportResult = ref<UserImportResult | null>(null);
const entitlementPlanForm = reactive({
name: "",
planType: "basic" as "basic" | "deep" | "addon" | "teacher",
planType: "basic" as "basic" | "deep" | "addon",
description: "",
validityDays: null as number | null,
monthlyTopicLimit: 30 as number | null,
@@ -343,7 +343,6 @@ function planTypeLabel(type: string) {
basic: "基础版",
deep: "深度陪伴版",
addon: "高频加购包",
teacher: "老师工作版",
}[type] || type;
}
@@ -423,7 +422,7 @@ async function generateUserReport(type: "weekly" | "monthly" | "stage") {
try {
await api.generateUserReport(selectedUserDetail.value.user.id, { reportType: type });
selectedUserDetail.value = await api.userDetail(selectedUserDetail.value.user.id);
ElMessage.success("报告已生成");
ElMessage.success("报告任务已进入后台队列,不会阻塞聊天服务");
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : "报告生成失败");
} finally {
@@ -431,6 +430,27 @@ async function generateUserReport(type: "weekly" | "monthly" | "stage") {
}
}
async function refreshSelectedUserDetail() {
if (!selectedUserDetail.value) return;
userDetailLoading.value = true;
try {
selectedUserDetail.value = await api.userDetail(selectedUserDetail.value.user.id);
} finally {
userDetailLoading.value = false;
}
}
function reportStatusLabel(status: string) {
const labels: Record<string, string> = {
pending: "等待生成",
running: "生成中",
success: "已完成",
empty: "暂无沉淀",
failed: "生成失败",
};
return labels[status] || status;
}
async function loadRecordTab(tab = recordTab.value) {
loading.value = true;
try {
@@ -1231,7 +1251,6 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<el-option label="基础版" value="basic" />
<el-option label="深度陪伴版" value="deep" />
<el-option label="高频加购包" value="addon" />
<el-option label="老师工作版" value="teacher" />
</el-select>
</el-form-item>
<el-form-item label="有效天数">
@@ -1727,6 +1746,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<p>基于已沉淀的主题摘要生成不读取整段原始聊天同一周期重复生成会覆盖旧报告</p>
</div>
<div class="user-report-actions">
<el-button size="small" @click="refreshSelectedUserDetail">刷新状态</el-button>
<el-button size="small" :loading="userReportGenerating === 'weekly'" @click="generateUserReport('weekly')">生成周报</el-button>
<el-button size="small" :loading="userReportGenerating === 'monthly'" @click="generateUserReport('monthly')">生成月报</el-button>
<el-button size="small" :loading="userReportGenerating === 'stage'" @click="generateUserReport('stage')">生成阶段总结</el-button>
@@ -1736,19 +1756,24 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
<el-collapse-item
v-for="report in selectedUserDetail.recentReports"
:key="report.id"
:title="`#${report.id} ${report.title} / ${report.status}`"
:title="`#${report.id} ${report.title} / ${reportStatusLabel(report.status)}`"
>
<section class="topic-summary-card">
<div class="topic-summary-meta">
<span>状态{{ reportStatusLabel(report.status) }}</span>
<span>类型{{ report.reportTypeLabel }}</span>
<span>周期{{ report.periodStart }} {{ report.periodEnd }}</span>
<span>来源主题{{ report.sourceTopicIds.length }}</span>
<span>来源摘要{{ report.sourceSummaryIds.length }}</span>
<span>模型{{ report.modelName || '-' }}</span>
<span>生成{{ report.generatedAt }}</span>
<span>触发方式{{ report.generatedBy.startsWith('schedule:') ? '系统定时' : '管理员手动' }}</span>
<span>执行次数{{ report.attemptCount }}/{{ report.maxAttempts }}</span>
<span v-if="report.nextRunAt">下次执行{{ report.nextRunAt }}</span>
<span>完成时间{{ report.finishedAt || '-' }}</span>
</div>
<p v-if="report.errorMessage" class="report-error">生成失败{{ report.errorMessage }}</p>
<pre>{{ report.content }}</pre>
<p v-if="report.errorMessage" class="report-error">最近错误{{ report.errorMessage }}</p>
<pre v-if="report.content">{{ report.content }}</pre>
<p v-else-if="report.status === 'pending' || report.status === 'running'" class="report-pending">后台正在生成报告可以先关闭抽屉稍后回来查看</p>
</section>
</el-collapse-item>
</el-collapse>

View File

@@ -2102,6 +2102,15 @@ textarea {
color: #b42318;
}
.report-pending {
margin: 0;
padding: 12px 14px;
border-radius: 10px;
background: #f2f8f6;
color: #557068;
line-height: 1.6;
}
.chat-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));

View File

@@ -98,7 +98,7 @@ export interface AdminUserMetrics {
export interface EntitlementPlan {
id: number;
name: string;
planType: "basic" | "deep" | "addon" | "teacher";
planType: "basic" | "deep" | "addon";
description?: string | null;
validityDays?: number | null;
monthlyTopicLimit?: number | null;
@@ -519,6 +519,11 @@ export interface PeriodicReportRecord {
status: "success" | "failed" | "empty" | string;
errorMessage?: string | null;
generatedBy: string;
attemptCount: number;
maxAttempts: number;
nextRunAt?: string | null;
lastStartedAt?: string | null;
finishedAt?: string | null;
generatedAt: string;
createdAt: string;
updatedAt: string;

View File

@@ -41,6 +41,15 @@ CHAT_MAX_QUEUE_SIZE=20
CHAT_QUEUE_TIMEOUT_SECONDS=60
CHAT_ACTIVE_LEASE_SECONDS=900
# 周报在每周一 02:00、月报在每月 1 日 03:00 生成,时间按 PERIODIC_REPORT_TIMEZONE 解释。
PERIODIC_REPORT_WORKER_ENABLED=true
PERIODIC_REPORT_POLL_SECONDS=5
PERIODIC_REPORT_STALE_MINUTES=30
PERIODIC_REPORT_MAX_ATTEMPTS=3
PERIODIC_REPORT_WEEKLY_ENABLED=true
PERIODIC_REPORT_MONTHLY_ENABLED=true
PERIODIC_REPORT_TIMEZONE=Asia/Shanghai
# 本地开发可以使用开发密码;生产环境必须改成高强度密码,且 APP_ENV=production 时不能使用 admin123456
BOOTSTRAP_ADMIN_USERNAME=admin
BOOTSTRAP_ADMIN_PASSWORD=admin123456

View File

@@ -0,0 +1,62 @@
"""add durable periodic report job fields
Revision ID: 0021_periodic_report_async_jobs
Revises: 0020_question_insight_persistence
Create Date: 2026-07-31 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "0021_periodic_report_async_jobs"
down_revision = "0020_question_insight_persistence"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"sys_periodic_report",
sa.Column("attempt_count", sa.Integer(), nullable=False, server_default="0"),
)
op.add_column(
"sys_periodic_report",
sa.Column("max_attempts", sa.Integer(), nullable=False, server_default="3"),
)
op.add_column("sys_periodic_report", sa.Column("next_run_at", sa.DateTime(), nullable=True))
op.add_column("sys_periodic_report", sa.Column("locked_at", sa.DateTime(), nullable=True))
op.add_column("sys_periodic_report", sa.Column("locked_by", sa.String(length=120), nullable=True))
op.add_column("sys_periodic_report", sa.Column("last_started_at", sa.DateTime(), nullable=True))
op.add_column("sys_periodic_report", sa.Column("finished_at", sa.DateTime(), nullable=True))
op.execute(
"UPDATE sys_periodic_report "
"SET finished_at = generated_at "
"WHERE status IN ('success', 'empty', 'failed')"
)
op.execute("UPDATE sys_entitlement_plan SET status = 0 WHERE plan_type = 'teacher'")
op.create_index(
"ix_periodic_report_job_due",
"sys_periodic_report",
["status", "next_run_at", "id"],
)
op.create_index(
"ix_periodic_report_job_stale",
"sys_periodic_report",
["status", "locked_at"],
)
def downgrade() -> None:
op.execute("UPDATE sys_entitlement_plan SET status = 1 WHERE plan_type = 'teacher'")
op.drop_index("ix_periodic_report_job_stale", table_name="sys_periodic_report")
op.drop_index("ix_periodic_report_job_due", table_name="sys_periodic_report")
op.drop_column("sys_periodic_report", "finished_at")
op.drop_column("sys_periodic_report", "last_started_at")
op.drop_column("sys_periodic_report", "locked_by")
op.drop_column("sys_periodic_report", "locked_at")
op.drop_column("sys_periodic_report", "next_run_at")
op.drop_column("sys_periodic_report", "max_attempts")
op.drop_column("sys_periodic_report", "attempt_count")

View File

@@ -295,7 +295,7 @@ def generate_user_report(
if payload.reportType not in ("weekly", "monthly", "stage"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的报告类型")
try:
report = PeriodicReportService.generate_for_user(
report = PeriodicReportService.enqueue_for_user(
db,
user=user,
report_type=payload.reportType, # type: ignore[arg-type]
@@ -305,9 +305,10 @@ def generate_user_report(
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
OperationLogService.write(db, admin_id=current_admin.id, module="user_report", action="generate", target_id=report.id)
OperationLogService.write(db, admin_id=current_admin.id, module="user_report", action="enqueue", target_id=report.id)
db.commit()
return api_success(periodic_report_dict(report))
db.refresh(report)
return api_success(periodic_report_dict(report), message="报告任务已进入后台队列")
@router.put("/user/{user_id}")

View File

@@ -62,5 +62,10 @@ def periodic_reports(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
reports = PeriodicReportService.list_user_reports(db, user_id=current_user.id, limit=max(1, min(limit, 50)))
reports = PeriodicReportService.list_user_reports(
db,
user_id=current_user.id,
limit=max(1, min(limit, 50)),
statuses=("success",),
)
return api_success([periodic_report_dict(item) for item in reports])

View File

@@ -63,6 +63,13 @@ class Settings(BaseSettings):
chat_max_queue_size: int = 20
chat_queue_timeout_seconds: int = 60
chat_active_lease_seconds: int = 900
periodic_report_worker_enabled: bool = True
periodic_report_poll_seconds: int = 5
periodic_report_stale_minutes: int = 30
periodic_report_max_attempts: int = 3
periodic_report_weekly_enabled: bool = True
periodic_report_monthly_enabled: bool = True
periodic_report_timezone: str = "Asia/Shanghai"
bootstrap_admin_username: str = ""
bootstrap_admin_password: str = ""
bootstrap_admin_name: str = "系统管理员"

View File

@@ -12,6 +12,7 @@ from app.core.exception_handlers import register_exception_handlers
from app.core.observability import RequestObservabilityMiddleware, configure_logging
from app.services.secret_service import SecretService
from app.services.maintenance_service import MaintenanceService
from app.services.periodic_report_worker import PeriodicReportWorker
import asyncio
@@ -22,14 +23,17 @@ async def lifespan(app: FastAPI):
if settings.auto_create_tables:
create_tables()
maintenance_task = asyncio.create_task(MaintenanceService.run_forever())
periodic_report_task = asyncio.create_task(PeriodicReportWorker.run_forever())
try:
yield
finally:
maintenance_task.cancel()
try:
await maintenance_task
except asyncio.CancelledError:
pass
for task in (maintenance_task, periodic_report_task):
task.cancel()
for task in (maintenance_task, periodic_report_task):
try:
await task
except asyncio.CancelledError:
pass
settings = get_settings()

View File

@@ -108,6 +108,8 @@ class PeriodicReport(Base):
UniqueConstraint("user_id", "report_type", "period_start", "period_end", name="uq_sys_periodic_report_period"),
Index("ix_sys_periodic_report_user_type_created", "user_id", "report_type", "created_at"),
Index("ix_sys_periodic_report_status_created", "status", "created_at"),
Index("ix_periodic_report_job_due", "status", "next_run_at", "id"),
Index("ix_periodic_report_job_stale", "status", "locked_at"),
)
id: Mapped[int] = mapped_column(PRIMARY_KEY_TYPE, primary_key=True, autoincrement=True)
@@ -123,6 +125,13 @@ class PeriodicReport(Base):
status: Mapped[str] = mapped_column(String(20), default="success", index=True, nullable=False)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
generated_by: Mapped[str] = mapped_column(String(30), default="manual", nullable=False)
attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
max_attempts: Mapped[int] = mapped_column(Integer, default=3, nullable=False)
next_run_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
locked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
locked_by: Mapped[str | None] = mapped_column(String(120), nullable=True)
last_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
finished_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
generated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)

View File

@@ -72,7 +72,7 @@ class AdminUserImportRequest(BaseModel):
class EntitlementPlanSaveRequest(BaseModel):
name: str = Field(min_length=1, max_length=80)
planType: Literal["basic", "deep", "addon", "teacher"] = "basic"
planType: Literal["basic", "deep", "addon"] = "basic"
description: str | None = Field(default=None, max_length=255)
validityDays: int | None = Field(default=None, ge=1, le=3650)
monthlyTopicLimit: int | None = Field(default=None, ge=0, le=100000)

View File

@@ -41,7 +41,7 @@ class EntitlementView:
class EntitlementService:
@staticmethod
def list_plans(db: Session, *, include_disabled: bool = False) -> list[EntitlementPlan]:
query = select(EntitlementPlan)
query = select(EntitlementPlan).where(EntitlementPlan.plan_type != "teacher")
if not include_disabled:
query = query.where(EntitlementPlan.status == 1)
return list(db.scalars(query.order_by(EntitlementPlan.sort_order.asc(), EntitlementPlan.id.asc())))
@@ -50,7 +50,11 @@ class EntitlementService:
def default_plan(db: Session) -> EntitlementPlan | None:
plan = db.scalar(
select(EntitlementPlan)
.where(EntitlementPlan.plan_type == DEFAULT_PLAN_TYPE, EntitlementPlan.status == 1)
.where(
EntitlementPlan.plan_type == DEFAULT_PLAN_TYPE,
EntitlementPlan.plan_type != "teacher",
EntitlementPlan.status == 1,
)
.order_by(EntitlementPlan.sort_order.asc(), EntitlementPlan.id.asc())
.limit(1)
)
@@ -58,7 +62,7 @@ class EntitlementService:
return plan
return db.scalar(
select(EntitlementPlan)
.where(EntitlementPlan.status == 1)
.where(EntitlementPlan.status == 1, EntitlementPlan.plan_type != "teacher")
.order_by(EntitlementPlan.sort_order.asc(), EntitlementPlan.id.asc())
.limit(1)
)
@@ -73,6 +77,7 @@ class EntitlementService:
UserEntitlement.user_id == user.id,
UserEntitlement.status == "active",
EntitlementPlan.status == 1,
EntitlementPlan.plan_type != "teacher",
)
.where((UserEntitlement.effective_at.is_(None)) | (UserEntitlement.effective_at <= now))
.where((UserEntitlement.expired_at.is_(None)) | (UserEntitlement.expired_at >= now))
@@ -113,7 +118,7 @@ class EntitlementService:
remark: str | None = None,
) -> UserEntitlement:
plan = db.get(EntitlementPlan, plan_id)
if plan is None or plan.status != 1:
if plan is None or plan.status != 1 or plan.plan_type == "teacher":
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="权益版本不存在或已停用")
now = _now()
if effective_at is not None:

View File

@@ -2,12 +2,14 @@ from __future__ import annotations
import json
from datetime import UTC, datetime, timedelta
from typing import Literal
from typing import Iterable, Literal
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.models.chat import TopicSession
from app.core.config import get_settings
from app.models.growth import PeriodicReport, TopicSummary, UserGrowthProfile
from app.models.user import User
from app.services.model_service import ModelClientService
@@ -24,29 +26,98 @@ REPORT_TYPE_LABELS = {
class PeriodicReportService:
@staticmethod
def default_period(report_type: ReportType, now: datetime | None = None) -> tuple[datetime, datetime]:
current = (now or datetime.now(UTC)).replace(tzinfo=None)
if report_type == "weekly":
end = current
start = end - timedelta(days=7)
elif report_type == "monthly":
end = current
start = end - timedelta(days=30)
else:
end = current
start = end - timedelta(days=150)
current = now or datetime.now(UTC)
if report_type in {"weekly", "monthly"}:
return calendar_period(
report_type,
current,
get_settings().periodic_report_timezone,
respect_schedule_delay=False,
)
end = current.astimezone(UTC).replace(tzinfo=None) if current.tzinfo else current
start = end - timedelta(days=150)
return start.replace(microsecond=0), end.replace(microsecond=0)
@staticmethod
def list_user_reports(db: Session, *, user_id: int, limit: int = 20) -> list[PeriodicReport]:
def list_user_reports(
db: Session,
*,
user_id: int,
limit: int = 20,
statuses: Iterable[str] | None = None,
) -> list[PeriodicReport]:
query = select(PeriodicReport).where(PeriodicReport.user_id == user_id)
if statuses is not None:
query = query.where(PeriodicReport.status.in_(tuple(statuses)))
return list(
db.scalars(
select(PeriodicReport)
.where(PeriodicReport.user_id == user_id)
query
.order_by(PeriodicReport.period_end.desc(), PeriodicReport.id.desc())
.limit(limit)
)
)
@staticmethod
def enqueue_for_user(
db: Session,
*,
user: User,
report_type: ReportType,
period_start: datetime | None = None,
period_end: datetime | None = None,
generated_by: str = "manual",
force: bool = True,
) -> PeriodicReport:
period_start, period_end = _resolve_period(report_type, period_start, period_end)
report = _find_report(
db,
user_id=user.id,
report_type=report_type,
period_start=period_start,
period_end=period_end,
)
if report is None:
report = _new_report(
user=user,
report_type=report_type,
period_start=period_start,
period_end=period_end,
)
try:
# 同周期并发入队由唯一约束兜底,不会产生重复报告任务。
with db.begin_nested():
db.add(report)
db.flush()
except IntegrityError:
report = _find_report(
db,
user_id=user.id,
report_type=report_type,
period_start=period_start,
period_end=period_end,
)
if report is None:
raise
elif not force:
return report
if report.status == "running":
return report
report.title = _report_title(report_type, period_start, period_end)
report.status = "pending"
report.error_message = None
report.generated_by = generated_by
report.attempt_count = 0
report.max_attempts = max(1, get_settings().periodic_report_max_attempts)
report.next_run_at = _now()
report.locked_at = None
report.locked_by = None
report.last_started_at = None
report.finished_at = None
db.add(report)
db.flush()
return report
@staticmethod
def generate_for_user(
db: Session,
@@ -57,25 +128,44 @@ class PeriodicReportService:
period_end: datetime | None = None,
generated_by: str = "manual",
) -> PeriodicReport:
period_start, period_end = _resolve_period(report_type, period_start, period_end)
report = _find_report(
db,
user_id=user.id,
report_type=report_type,
period_start=period_start,
period_end=period_end,
) or _new_report(
user=user,
report_type=report_type,
period_start=period_start,
period_end=period_end,
)
report.generated_by = generated_by
PeriodicReportService.generate_existing(db, report=report, user=user)
report.finished_at = _now()
report.next_run_at = None
report.locked_at = None
report.locked_by = None
db.add(report)
db.commit()
db.refresh(report)
return report
@staticmethod
def generate_existing(db: Session, *, report: PeriodicReport, user: User) -> PeriodicReport:
report_type = report.report_type
if report_type not in REPORT_TYPE_LABELS:
raise ValueError("不支持的报告类型")
if period_start is None or period_end is None:
default_start, default_end = PeriodicReportService.default_period(report_type)
period_start = period_start or default_start
period_end = period_end or default_end
period_start = period_start.replace(tzinfo=None, microsecond=0)
period_end = period_end.replace(tzinfo=None, microsecond=0)
if period_start >= period_end:
raise ValueError("报告开始时间必须早于结束时间")
report = _get_or_create_report(db, user=user, report_type=report_type, period_start=period_start, period_end=period_end)
period_start = report.period_start
period_end = report.period_end
report.title = _report_title(report_type, period_start, period_end)
summaries = _period_summaries(db, user_id=user.id, period_start=period_start, period_end=period_end)
profile = db.scalar(select(UserGrowthProfile).where(UserGrowthProfile.user_id == user.id))
topic_ids = sorted({int(item.topic_session_id) for item in summaries})
summary_ids = [int(item.id) for item in summaries]
report.source_topic_ids = json.dumps(topic_ids, ensure_ascii=False)
report.source_summary_ids = json.dumps(summary_ids, ensure_ascii=False)
report.generated_by = generated_by
report.generated_at = _now()
if not summaries:
@@ -83,8 +173,6 @@ class PeriodicReportService:
report.error_message = None
report.content = _empty_report_content(report_type=report_type, period_start=period_start, period_end=period_end)
db.add(report)
db.commit()
db.refresh(report)
return report
try:
@@ -99,8 +187,6 @@ class PeriodicReportService:
report.error_message = str(exc)[:2000]
report.content = _fallback_report(summaries)
db.add(report)
db.commit()
db.refresh(report)
return report
@@ -110,8 +196,8 @@ def periodic_report_dict(report: PeriodicReport) -> dict:
"userId": report.user_id,
"reportType": report.report_type,
"reportTypeLabel": REPORT_TYPE_LABELS.get(report.report_type, report.report_type),
"periodStart": report.period_start,
"periodEnd": report.period_end,
"periodStart": _local_datetime(report.period_start),
"periodEnd": _local_datetime(report.period_end),
"title": report.title,
"content": report.content,
"sourceSummaryIds": _parse_json_list(report.source_summary_ids),
@@ -120,40 +206,119 @@ def periodic_report_dict(report: PeriodicReport) -> dict:
"status": report.status,
"errorMessage": report.error_message,
"generatedBy": report.generated_by,
"attemptCount": report.attempt_count,
"maxAttempts": report.max_attempts,
"nextRunAt": report.next_run_at,
"lastStartedAt": report.last_started_at,
"finishedAt": report.finished_at,
"generatedAt": report.generated_at,
"createdAt": report.created_at,
"updatedAt": report.updated_at,
}
def _get_or_create_report(
def calendar_period(
report_type: str,
now_utc: datetime,
timezone_name: str = "Asia/Shanghai",
*,
respect_schedule_delay: bool,
) -> tuple[datetime, datetime]:
"""计算最近一个完整自然周或自然月,返回 UTC 无时区时间。"""
try:
timezone = ZoneInfo(timezone_name)
except ZoneInfoNotFoundError:
timezone = ZoneInfo("Asia/Shanghai")
aware_utc = now_utc.replace(tzinfo=UTC) if now_utc.tzinfo is None else now_utc.astimezone(UTC)
local_now = aware_utc.astimezone(timezone)
if report_type == "weekly":
period_end_local = (local_now - timedelta(days=local_now.weekday())).replace(
hour=0,
minute=0,
second=0,
microsecond=0,
)
if respect_schedule_delay and local_now < period_end_local + timedelta(hours=2):
period_end_local -= timedelta(days=7)
period_start_local = period_end_local - timedelta(days=7)
elif report_type == "monthly":
period_end_local = local_now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
if respect_schedule_delay and local_now < period_end_local + timedelta(hours=3):
period_end_local = _previous_month_start(period_end_local)
period_start_local = _previous_month_start(period_end_local)
else:
raise ValueError("不支持的自然周期报告类型")
return (
period_start_local.astimezone(UTC).replace(tzinfo=None),
period_end_local.astimezone(UTC).replace(tzinfo=None),
)
def _find_report(
db: Session,
*,
user_id: int,
report_type: ReportType,
period_start: datetime,
period_end: datetime,
) -> PeriodicReport | None:
return db.scalar(
select(PeriodicReport).where(
PeriodicReport.user_id == user_id,
PeriodicReport.report_type == report_type,
PeriodicReport.period_start == period_start,
PeriodicReport.period_end == period_end,
)
)
def _new_report(
*,
user: User,
report_type: ReportType,
period_start: datetime,
period_end: datetime,
) -> PeriodicReport:
report = db.scalar(
select(PeriodicReport).where(
PeriodicReport.user_id == user.id,
PeriodicReport.report_type == report_type,
PeriodicReport.period_start == period_start,
PeriodicReport.period_end == period_end,
)
return PeriodicReport(
user_id=user.id,
report_type=report_type,
period_start=period_start,
period_end=period_end,
title=_report_title(report_type, period_start, period_end),
)
title = f"{REPORT_TYPE_LABELS[report_type]}{period_start:%Y-%m-%d}{period_end:%Y-%m-%d}"
if report is None:
report = PeriodicReport(
user_id=user.id,
report_type=report_type,
period_start=period_start,
period_end=period_end,
title=title,
)
else:
report.title = title
return report
def _resolve_period(
report_type: ReportType,
period_start: datetime | None,
period_end: datetime | None,
) -> tuple[datetime, datetime]:
if report_type not in REPORT_TYPE_LABELS:
raise ValueError("不支持的报告类型")
if period_start is None or period_end is None:
default_start, default_end = PeriodicReportService.default_period(report_type)
period_start = period_start or default_start
period_end = period_end or default_end
start = period_start.replace(tzinfo=None, microsecond=0)
end = period_end.replace(tzinfo=None, microsecond=0)
if start >= end:
raise ValueError("报告开始时间必须早于结束时间")
return start, end
def _report_title(report_type: str, period_start: datetime, period_end: datetime) -> str:
local_start = _local_datetime(period_start)
local_end = _local_datetime(period_end)
return f"{REPORT_TYPE_LABELS[report_type]}{local_start:%Y-%m-%d}{local_end:%Y-%m-%d}"
def _previous_month_start(value: datetime) -> datetime:
if value.month == 1:
return value.replace(year=value.year - 1, month=12, day=1)
return value.replace(month=value.month - 1, day=1)
def _period_summaries(db: Session, *, user_id: int, period_start: datetime, period_end: datetime) -> list[TopicSummary]:
@@ -164,7 +329,7 @@ def _period_summaries(db: Session, *, user_id: int, period_start: datetime, peri
TopicSummary.user_id == user_id,
TopicSummary.status == "success",
TopicSummary.generated_at >= period_start,
TopicSummary.generated_at <= period_end,
TopicSummary.generated_at < period_end,
)
.order_by(TopicSummary.generated_at.asc(), TopicSummary.id.asc())
.limit(200)
@@ -181,6 +346,8 @@ def _report_prompt(
summaries: list[TopicSummary],
profile: UserGrowthProfile | None,
) -> str:
local_start = _local_datetime(period_start)
local_end = _local_datetime(period_end)
summary_text = "\n\n".join(
(
f"主题摘要 {index}\n"
@@ -196,7 +363,7 @@ def _report_prompt(
)
return (
f"请为学员“{user.name or user.nickname or user.phone}”生成一份{REPORT_TYPE_LABELS[report_type]}\n"
f"周期:{period_start:%Y-%m-%d %H:%M}{period_end:%Y-%m-%d %H:%M}\n\n"
f"周期:{local_start:%Y-%m-%d %H:%M}{local_end:%Y-%m-%d %H:%M}\n\n"
"产品定位:这是大本营千问千答的实修陪伴报告,不写成医疗诊断、心理咨询结论或营销文。\n"
"表达方向:回到当下、回到自身、觉察情绪和身体感受,如实释放;建议适度,不要给过多术层面的复杂方案。\n"
"请使用 Markdown 输出,结构包含:本周期主要议题、做过或被建议的功课、反复出现的情绪/身体模式、已有变化、下一步观察方向、可以带给老师确认的问题。\n"
@@ -218,9 +385,11 @@ def _fallback_report(summaries: list[TopicSummary]) -> str:
def _empty_report_content(*, report_type: ReportType, period_start: datetime, period_end: datetime) -> str:
local_start = _local_datetime(period_start)
local_end = _local_datetime(period_end)
return (
f"## {REPORT_TYPE_LABELS[report_type]}\n\n"
f"周期:{period_start:%Y-%m-%d}{period_end:%Y-%m-%d}\n\n"
f"周期:{local_start:%Y-%m-%d}{local_end:%Y-%m-%d}\n\n"
"本周期还没有可用于生成报告的主题沉淀。可以在完成一次主题对话后,先点击“沉淀本主题”,再生成报告。"
)
@@ -239,3 +408,12 @@ def _parse_json_list(raw: str | None) -> list[int]:
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)
def _local_datetime(value: datetime) -> datetime:
try:
timezone = ZoneInfo(get_settings().periodic_report_timezone)
except ZoneInfoNotFoundError:
timezone = ZoneInfo("Asia/Shanghai")
aware_utc = value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC)
return aware_utc.astimezone(timezone).replace(tzinfo=None)

View File

@@ -0,0 +1,321 @@
from __future__ import annotations
import asyncio
import logging
import os
import socket
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import exists, or_, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.database import SessionLocal
from app.models.ai_config import SystemConfig
from app.models.growth import PeriodicReport, TopicSummary
from app.models.user import User
from app.services.entitlement_service import EntitlementService
from app.services.periodic_report_service import PeriodicReportService, calendar_period
from app.services.redis_client import get_sync_redis_client
logger = logging.getLogger(__name__)
WORKER_LOCK_KEY = "periodic-report:worker:lock"
WORKER_LOCK_TTL_SECONDS = 900
SCHEDULE_STATE_KEYS = {
"weekly": "periodic_report_weekly_last_period_end",
"monthly": "periodic_report_monthly_last_period_end",
}
class PeriodicReportWorker:
"""数据库持久化报告队列。
Redis 只用于限制多进程并发;任务状态、重试和调度进度均以 MySQL 为准,
因此 Redis 暂时不可用或服务重启时不会丢任务。
"""
@classmethod
async def run_forever(cls) -> None:
settings = get_settings()
if not settings.periodic_report_worker_enabled:
logger.info("periodic report worker disabled")
return
worker_id = f"{socket.gethostname()}:{os.getpid()}:{uuid.uuid4().hex[:8]}"
poll_seconds = max(1, settings.periodic_report_poll_seconds)
while True:
try:
processed = await asyncio.to_thread(cls.run_once, worker_id)
except Exception:
processed = False
logger.exception("periodic report worker iteration failed")
await asyncio.sleep(0 if processed else poll_seconds)
@classmethod
def run_once(cls, worker_id: str) -> bool:
redis, acquired = _acquire_worker_lock(worker_id)
if not acquired:
return False
try:
now = _now()
with SessionLocal() as db:
cls.recover_stale_jobs(db, now=now)
cls.enqueue_due_schedules(db, now_utc=now)
db.commit()
with SessionLocal() as db:
report_id = cls.claim_next(db, worker_id=worker_id, now=now)
if report_id is None:
return False
with SessionLocal() as db:
cls.execute_claimed(db, report_id=report_id, worker_id=worker_id)
return True
finally:
_release_worker_lock(redis, worker_id)
@staticmethod
def claim_next(db: Session, *, worker_id: str, now: datetime | None = None) -> int | None:
current = now or _now()
report = db.scalar(
select(PeriodicReport)
.where(
PeriodicReport.status == "pending",
PeriodicReport.attempt_count < PeriodicReport.max_attempts,
or_(PeriodicReport.next_run_at.is_(None), PeriodicReport.next_run_at <= current),
)
.order_by(PeriodicReport.next_run_at.asc(), PeriodicReport.id.asc())
.with_for_update(skip_locked=True)
.limit(1)
)
if report is None:
db.rollback()
return None
report.status = "running"
report.attempt_count += 1
report.locked_at = current
report.locked_by = worker_id
report.last_started_at = current
report.finished_at = None
db.add(report)
db.commit()
return report.id
@staticmethod
def execute_claimed(db: Session, *, report_id: int, worker_id: str) -> PeriodicReport | None:
report = db.get(PeriodicReport, report_id)
if report is None or report.status != "running" or report.locked_by != worker_id:
return report
user = db.get(User, report.user_id)
if user is None or user.is_deleted or user.status != 1:
_finish_permanent_failure(report, "用户不存在或已停用")
db.commit()
return report
if report.generated_by.startswith("schedule:"):
entitlement = EntitlementService.active_entitlement(db, user)
if not entitlement.enable_periodic_reports:
_finish_permanent_failure(report, "用户当前权益未开启周期报告")
db.commit()
return report
try:
PeriodicReportService.generate_existing(db, report=report, user=user)
except Exception as exc:
report.status = "failed"
report.error_message = str(exc)[:2000]
now = _now()
if report.status == "failed" and report.attempt_count < report.max_attempts:
retry_seconds = min(300, 30 * (2 ** max(0, report.attempt_count - 1)))
report.status = "pending"
report.next_run_at = now + timedelta(seconds=retry_seconds)
report.finished_at = None
else:
report.next_run_at = None
report.finished_at = now
report.locked_at = None
report.locked_by = None
db.add(report)
db.commit()
db.refresh(report)
return report
@staticmethod
def recover_stale_jobs(db: Session, *, now: datetime | None = None) -> int:
current = now or _now()
stale_before = current - timedelta(minutes=max(5, get_settings().periodic_report_stale_minutes))
reports = list(
db.scalars(
select(PeriodicReport)
.where(
PeriodicReport.status == "running",
PeriodicReport.locked_at.is_not(None),
PeriodicReport.locked_at < stale_before,
)
.order_by(PeriodicReport.id.asc())
.limit(100)
.with_for_update(skip_locked=True)
)
)
for report in reports:
if report.attempt_count >= report.max_attempts:
report.status = "failed"
report.finished_at = current
report.next_run_at = None
else:
report.status = "pending"
report.next_run_at = current
report.finished_at = None
report.error_message = _append_error(report.error_message, "任务执行进程中断,系统已自动恢复")
report.locked_at = None
report.locked_by = None
db.add(report)
return len(reports)
@staticmethod
def enqueue_due_schedules(db: Session, *, now_utc: datetime | None = None) -> dict[str, int]:
settings = get_settings()
current = now_utc or _now()
result: dict[str, int] = {}
schedule_flags = {
"weekly": settings.periodic_report_weekly_enabled,
"monthly": settings.periodic_report_monthly_enabled,
}
for report_type, enabled in schedule_flags.items():
if not enabled:
continue
period = scheduled_period(report_type, current, settings.periodic_report_timezone)
if period is None:
continue
period_start, period_end = period
state_key = SCHEDULE_STATE_KEYS[report_type]
state = db.scalar(select(SystemConfig).where(SystemConfig.config_key == state_key))
period_marker = period_end.isoformat()
if state is not None and state.config_value == period_marker:
continue
enqueued = _enqueue_scheduled_users(
db,
report_type=report_type,
period_start=period_start,
period_end=period_end,
now=current,
)
if state is None:
state = SystemConfig(
config_key=state_key,
config_value=period_marker,
description=f"{report_type} 周期报告最近一次完成入队的周期结束时间",
)
else:
state.config_value = period_marker
db.add(state)
result[report_type] = enqueued
return result
def scheduled_period(
report_type: str,
now_utc: datetime,
timezone_name: str = "Asia/Shanghai",
) -> tuple[datetime, datetime] | None:
"""返回最近一个已经到达生成时间的完整自然周或自然月。"""
if report_type not in {"weekly", "monthly"}:
return None
return calendar_period(
report_type,
now_utc,
timezone_name,
respect_schedule_delay=True,
)
def _enqueue_scheduled_users(
db: Session,
*,
report_type: str,
period_start: datetime,
period_end: datetime,
now: datetime,
) -> int:
has_summary = exists(
select(TopicSummary.id).where(
TopicSummary.user_id == User.id,
TopicSummary.status == "success",
TopicSummary.generated_at >= period_start,
TopicSummary.generated_at < period_end,
)
)
users = list(
db.scalars(
select(User)
.where(
User.is_deleted == 0,
User.status == 1,
or_(User.effective_at.is_(None), User.effective_at <= now),
or_(User.expired_at.is_(None), User.expired_at >= now),
has_summary,
)
.order_by(User.id.asc())
)
)
enqueued = 0
for user in users:
entitlement = EntitlementService.active_entitlement(db, user)
if not entitlement.enable_periodic_reports:
continue
report = PeriodicReportService.enqueue_for_user(
db,
user=user,
report_type=report_type, # type: ignore[arg-type]
period_start=period_start,
period_end=period_end,
generated_by=f"schedule:{report_type}",
force=False,
)
if report.status == "pending":
enqueued += 1
return enqueued
def _finish_permanent_failure(report: PeriodicReport, message: str) -> None:
report.status = "failed"
report.error_message = message
report.next_run_at = None
report.locked_at = None
report.locked_by = None
report.finished_at = _now()
def _append_error(current: str | None, message: str) -> str:
if not current:
return message
return f"{current}\n{message}"[-2000:]
def _acquire_worker_lock(worker_id: str):
redis = get_sync_redis_client()
if redis is None:
return None, True
try:
return redis, bool(redis.set(WORKER_LOCK_KEY, worker_id, nx=True, ex=WORKER_LOCK_TTL_SECONDS))
except Exception:
logger.warning("redis unavailable for periodic report worker lock; falling back to database claim", exc_info=True)
return None, True
def _release_worker_lock(redis, worker_id: str) -> None:
if redis is None:
return
try:
redis.eval(
"if redis.call('get', KEYS[1]) == ARGV[1] then "
"return redis.call('del', KEYS[1]) else return 0 end",
1,
WORKER_LOCK_KEY,
worker_id,
)
except Exception:
logger.warning("failed to release periodic report worker lock", exc_info=True)
def _now() -> datetime:
return datetime.now(UTC).replace(tzinfo=None)

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 "")