feat: schedule periodic reports asynchronously
This commit is contained in:
@@ -21,6 +21,13 @@ CONFIG_ENCRYPTION_KEY=replace-with-generated-fernet-key
|
|||||||
BOOTSTRAP_ADMIN_USERNAME=admin
|
BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
BOOTSTRAP_ADMIN_PASSWORD=replace-with-strong-password
|
BOOTSTRAP_ADMIN_PASSWORD=replace-with-strong-password
|
||||||
BOOTSTRAP_ADMIN_NAME=系统管理员
|
BOOTSTRAP_ADMIN_NAME=系统管理员
|
||||||
|
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
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 内网穿透(frpc sidecar)
|
# 内网穿透(frpc sidecar)
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ const studentImportResult = ref<UserImportResult | null>(null);
|
|||||||
|
|
||||||
const entitlementPlanForm = reactive({
|
const entitlementPlanForm = reactive({
|
||||||
name: "",
|
name: "",
|
||||||
planType: "basic" as "basic" | "deep" | "addon" | "teacher",
|
planType: "basic" as "basic" | "deep" | "addon",
|
||||||
description: "",
|
description: "",
|
||||||
validityDays: null as number | null,
|
validityDays: null as number | null,
|
||||||
monthlyTopicLimit: 30 as number | null,
|
monthlyTopicLimit: 30 as number | null,
|
||||||
@@ -343,7 +343,6 @@ function planTypeLabel(type: string) {
|
|||||||
basic: "基础版",
|
basic: "基础版",
|
||||||
deep: "深度陪伴版",
|
deep: "深度陪伴版",
|
||||||
addon: "高频加购包",
|
addon: "高频加购包",
|
||||||
teacher: "老师工作版",
|
|
||||||
}[type] || type;
|
}[type] || type;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -423,7 +422,7 @@ async function generateUserReport(type: "weekly" | "monthly" | "stage") {
|
|||||||
try {
|
try {
|
||||||
await api.generateUserReport(selectedUserDetail.value.user.id, { reportType: type });
|
await api.generateUserReport(selectedUserDetail.value.user.id, { reportType: type });
|
||||||
selectedUserDetail.value = await api.userDetail(selectedUserDetail.value.user.id);
|
selectedUserDetail.value = await api.userDetail(selectedUserDetail.value.user.id);
|
||||||
ElMessage.success("报告已生成");
|
ElMessage.success("报告任务已进入后台队列,不会阻塞聊天服务");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error instanceof Error ? error.message : "报告生成失败");
|
ElMessage.error(error instanceof Error ? error.message : "报告生成失败");
|
||||||
} finally {
|
} 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) {
|
async function loadRecordTab(tab = recordTab.value) {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@@ -1231,7 +1251,6 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
|||||||
<el-option label="基础版" value="basic" />
|
<el-option label="基础版" value="basic" />
|
||||||
<el-option label="深度陪伴版" value="deep" />
|
<el-option label="深度陪伴版" value="deep" />
|
||||||
<el-option label="高频加购包" value="addon" />
|
<el-option label="高频加购包" value="addon" />
|
||||||
<el-option label="老师工作版" value="teacher" />
|
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="有效天数">
|
<el-form-item label="有效天数">
|
||||||
@@ -1727,6 +1746,7 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
|||||||
<p>基于已沉淀的主题摘要生成,不读取整段原始聊天;同一周期重复生成会覆盖旧报告。</p>
|
<p>基于已沉淀的主题摘要生成,不读取整段原始聊天;同一周期重复生成会覆盖旧报告。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="user-report-actions">
|
<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 === 'weekly'" @click="generateUserReport('weekly')">生成周报</el-button>
|
||||||
<el-button size="small" :loading="userReportGenerating === 'monthly'" @click="generateUserReport('monthly')">生成月报</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>
|
<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
|
<el-collapse-item
|
||||||
v-for="report in selectedUserDetail.recentReports"
|
v-for="report in selectedUserDetail.recentReports"
|
||||||
:key="report.id"
|
:key="report.id"
|
||||||
:title="`#${report.id} ${report.title} / ${report.status}`"
|
:title="`#${report.id} ${report.title} / ${reportStatusLabel(report.status)}`"
|
||||||
>
|
>
|
||||||
<section class="topic-summary-card">
|
<section class="topic-summary-card">
|
||||||
<div class="topic-summary-meta">
|
<div class="topic-summary-meta">
|
||||||
|
<span>状态:{{ reportStatusLabel(report.status) }}</span>
|
||||||
<span>类型:{{ report.reportTypeLabel }}</span>
|
<span>类型:{{ report.reportTypeLabel }}</span>
|
||||||
<span>周期:{{ report.periodStart }} 至 {{ report.periodEnd }}</span>
|
<span>周期:{{ report.periodStart }} 至 {{ report.periodEnd }}</span>
|
||||||
<span>来源主题:{{ report.sourceTopicIds.length }}</span>
|
<span>来源主题:{{ report.sourceTopicIds.length }}</span>
|
||||||
<span>来源摘要:{{ report.sourceSummaryIds.length }}</span>
|
<span>来源摘要:{{ report.sourceSummaryIds.length }}</span>
|
||||||
<span>模型:{{ report.modelName || '-' }}</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>
|
</div>
|
||||||
<p v-if="report.errorMessage" class="report-error">生成失败:{{ report.errorMessage }}</p>
|
<p v-if="report.errorMessage" class="report-error">最近错误:{{ report.errorMessage }}</p>
|
||||||
<pre>{{ report.content }}</pre>
|
<pre v-if="report.content">{{ report.content }}</pre>
|
||||||
|
<p v-else-if="report.status === 'pending' || report.status === 'running'" class="report-pending">后台正在生成报告,可以先关闭抽屉,稍后回来查看。</p>
|
||||||
</section>
|
</section>
|
||||||
</el-collapse-item>
|
</el-collapse-item>
|
||||||
</el-collapse>
|
</el-collapse>
|
||||||
|
|||||||
@@ -2102,6 +2102,15 @@ textarea {
|
|||||||
color: #b42318;
|
color: #b42318;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-pending {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #f2f8f6;
|
||||||
|
color: #557068;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-summary {
|
.chat-summary {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export interface AdminUserMetrics {
|
|||||||
export interface EntitlementPlan {
|
export interface EntitlementPlan {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
planType: "basic" | "deep" | "addon" | "teacher";
|
planType: "basic" | "deep" | "addon";
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
validityDays?: number | null;
|
validityDays?: number | null;
|
||||||
monthlyTopicLimit?: number | null;
|
monthlyTopicLimit?: number | null;
|
||||||
@@ -519,6 +519,11 @@ export interface PeriodicReportRecord {
|
|||||||
status: "success" | "failed" | "empty" | string;
|
status: "success" | "failed" | "empty" | string;
|
||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
generatedBy: string;
|
generatedBy: string;
|
||||||
|
attemptCount: number;
|
||||||
|
maxAttempts: number;
|
||||||
|
nextRunAt?: string | null;
|
||||||
|
lastStartedAt?: string | null;
|
||||||
|
finishedAt?: string | null;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
|||||||
@@ -41,6 +41,15 @@ CHAT_MAX_QUEUE_SIZE=20
|
|||||||
CHAT_QUEUE_TIMEOUT_SECONDS=60
|
CHAT_QUEUE_TIMEOUT_SECONDS=60
|
||||||
CHAT_ACTIVE_LEASE_SECONDS=900
|
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
|
# 本地开发可以使用开发密码;生产环境必须改成高强度密码,且 APP_ENV=production 时不能使用 admin123456
|
||||||
BOOTSTRAP_ADMIN_USERNAME=admin
|
BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
BOOTSTRAP_ADMIN_PASSWORD=admin123456
|
BOOTSTRAP_ADMIN_PASSWORD=admin123456
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -295,7 +295,7 @@ def generate_user_report(
|
|||||||
if payload.reportType not in ("weekly", "monthly", "stage"):
|
if payload.reportType not in ("weekly", "monthly", "stage"):
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的报告类型")
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的报告类型")
|
||||||
try:
|
try:
|
||||||
report = PeriodicReportService.generate_for_user(
|
report = PeriodicReportService.enqueue_for_user(
|
||||||
db,
|
db,
|
||||||
user=user,
|
user=user,
|
||||||
report_type=payload.reportType, # type: ignore[arg-type]
|
report_type=payload.reportType, # type: ignore[arg-type]
|
||||||
@@ -305,9 +305,10 @@ def generate_user_report(
|
|||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from 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()
|
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}")
|
@router.put("/user/{user_id}")
|
||||||
|
|||||||
@@ -62,5 +62,10 @@ def periodic_reports(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
) -> dict:
|
) -> 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])
|
return api_success([periodic_report_dict(item) for item in reports])
|
||||||
|
|||||||
@@ -63,6 +63,13 @@ class Settings(BaseSettings):
|
|||||||
chat_max_queue_size: int = 20
|
chat_max_queue_size: int = 20
|
||||||
chat_queue_timeout_seconds: int = 60
|
chat_queue_timeout_seconds: int = 60
|
||||||
chat_active_lease_seconds: int = 900
|
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_username: str = ""
|
||||||
bootstrap_admin_password: str = ""
|
bootstrap_admin_password: str = ""
|
||||||
bootstrap_admin_name: str = "系统管理员"
|
bootstrap_admin_name: str = "系统管理员"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from app.core.exception_handlers import register_exception_handlers
|
|||||||
from app.core.observability import RequestObservabilityMiddleware, configure_logging
|
from app.core.observability import RequestObservabilityMiddleware, configure_logging
|
||||||
from app.services.secret_service import SecretService
|
from app.services.secret_service import SecretService
|
||||||
from app.services.maintenance_service import MaintenanceService
|
from app.services.maintenance_service import MaintenanceService
|
||||||
|
from app.services.periodic_report_worker import PeriodicReportWorker
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
@@ -22,12 +23,15 @@ async def lifespan(app: FastAPI):
|
|||||||
if settings.auto_create_tables:
|
if settings.auto_create_tables:
|
||||||
create_tables()
|
create_tables()
|
||||||
maintenance_task = asyncio.create_task(MaintenanceService.run_forever())
|
maintenance_task = asyncio.create_task(MaintenanceService.run_forever())
|
||||||
|
periodic_report_task = asyncio.create_task(PeriodicReportWorker.run_forever())
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
maintenance_task.cancel()
|
for task in (maintenance_task, periodic_report_task):
|
||||||
|
task.cancel()
|
||||||
|
for task in (maintenance_task, periodic_report_task):
|
||||||
try:
|
try:
|
||||||
await maintenance_task
|
await task
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ class PeriodicReport(Base):
|
|||||||
UniqueConstraint("user_id", "report_type", "period_start", "period_end", name="uq_sys_periodic_report_period"),
|
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_user_type_created", "user_id", "report_type", "created_at"),
|
||||||
Index("ix_sys_periodic_report_status_created", "status", "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)
|
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)
|
status: Mapped[str] = mapped_column(String(20), default="success", index=True, nullable=False)
|
||||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
generated_by: Mapped[str] = mapped_column(String(30), default="manual", nullable=False)
|
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)
|
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)
|
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)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class AdminUserImportRequest(BaseModel):
|
|||||||
|
|
||||||
class EntitlementPlanSaveRequest(BaseModel):
|
class EntitlementPlanSaveRequest(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=80)
|
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)
|
description: str | None = Field(default=None, max_length=255)
|
||||||
validityDays: int | None = Field(default=None, ge=1, le=3650)
|
validityDays: int | None = Field(default=None, ge=1, le=3650)
|
||||||
monthlyTopicLimit: int | None = Field(default=None, ge=0, le=100000)
|
monthlyTopicLimit: int | None = Field(default=None, ge=0, le=100000)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class EntitlementView:
|
|||||||
class EntitlementService:
|
class EntitlementService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def list_plans(db: Session, *, include_disabled: bool = False) -> list[EntitlementPlan]:
|
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:
|
if not include_disabled:
|
||||||
query = query.where(EntitlementPlan.status == 1)
|
query = query.where(EntitlementPlan.status == 1)
|
||||||
return list(db.scalars(query.order_by(EntitlementPlan.sort_order.asc(), EntitlementPlan.id.asc())))
|
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:
|
def default_plan(db: Session) -> EntitlementPlan | None:
|
||||||
plan = db.scalar(
|
plan = db.scalar(
|
||||||
select(EntitlementPlan)
|
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())
|
.order_by(EntitlementPlan.sort_order.asc(), EntitlementPlan.id.asc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
@@ -58,7 +62,7 @@ class EntitlementService:
|
|||||||
return plan
|
return plan
|
||||||
return db.scalar(
|
return db.scalar(
|
||||||
select(EntitlementPlan)
|
select(EntitlementPlan)
|
||||||
.where(EntitlementPlan.status == 1)
|
.where(EntitlementPlan.status == 1, EntitlementPlan.plan_type != "teacher")
|
||||||
.order_by(EntitlementPlan.sort_order.asc(), EntitlementPlan.id.asc())
|
.order_by(EntitlementPlan.sort_order.asc(), EntitlementPlan.id.asc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
@@ -73,6 +77,7 @@ class EntitlementService:
|
|||||||
UserEntitlement.user_id == user.id,
|
UserEntitlement.user_id == user.id,
|
||||||
UserEntitlement.status == "active",
|
UserEntitlement.status == "active",
|
||||||
EntitlementPlan.status == 1,
|
EntitlementPlan.status == 1,
|
||||||
|
EntitlementPlan.plan_type != "teacher",
|
||||||
)
|
)
|
||||||
.where((UserEntitlement.effective_at.is_(None)) | (UserEntitlement.effective_at <= now))
|
.where((UserEntitlement.effective_at.is_(None)) | (UserEntitlement.effective_at <= now))
|
||||||
.where((UserEntitlement.expired_at.is_(None)) | (UserEntitlement.expired_at >= now))
|
.where((UserEntitlement.expired_at.is_(None)) | (UserEntitlement.expired_at >= now))
|
||||||
@@ -113,7 +118,7 @@ class EntitlementService:
|
|||||||
remark: str | None = None,
|
remark: str | None = None,
|
||||||
) -> UserEntitlement:
|
) -> UserEntitlement:
|
||||||
plan = db.get(EntitlementPlan, plan_id)
|
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="权益版本不存在或已停用")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="权益版本不存在或已停用")
|
||||||
now = _now()
|
now = _now()
|
||||||
if effective_at is not None:
|
if effective_at is not None:
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import UTC, datetime, timedelta
|
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 import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.orm import Session
|
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.growth import PeriodicReport, TopicSummary, UserGrowthProfile
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.model_service import ModelClientService
|
from app.services.model_service import ModelClientService
|
||||||
@@ -24,29 +26,98 @@ REPORT_TYPE_LABELS = {
|
|||||||
class PeriodicReportService:
|
class PeriodicReportService:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def default_period(report_type: ReportType, now: datetime | None = None) -> tuple[datetime, datetime]:
|
def default_period(report_type: ReportType, now: datetime | None = None) -> tuple[datetime, datetime]:
|
||||||
current = (now or datetime.now(UTC)).replace(tzinfo=None)
|
current = now or datetime.now(UTC)
|
||||||
if report_type == "weekly":
|
if report_type in {"weekly", "monthly"}:
|
||||||
end = current
|
return calendar_period(
|
||||||
start = end - timedelta(days=7)
|
report_type,
|
||||||
elif report_type == "monthly":
|
current,
|
||||||
end = current
|
get_settings().periodic_report_timezone,
|
||||||
start = end - timedelta(days=30)
|
respect_schedule_delay=False,
|
||||||
else:
|
)
|
||||||
end = current
|
end = current.astimezone(UTC).replace(tzinfo=None) if current.tzinfo else current
|
||||||
start = end - timedelta(days=150)
|
start = end - timedelta(days=150)
|
||||||
return start.replace(microsecond=0), end.replace(microsecond=0)
|
return start.replace(microsecond=0), end.replace(microsecond=0)
|
||||||
|
|
||||||
@staticmethod
|
@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(
|
return list(
|
||||||
db.scalars(
|
db.scalars(
|
||||||
select(PeriodicReport)
|
query
|
||||||
.where(PeriodicReport.user_id == user_id)
|
|
||||||
.order_by(PeriodicReport.period_end.desc(), PeriodicReport.id.desc())
|
.order_by(PeriodicReport.period_end.desc(), PeriodicReport.id.desc())
|
||||||
.limit(limit)
|
.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
|
@staticmethod
|
||||||
def generate_for_user(
|
def generate_for_user(
|
||||||
db: Session,
|
db: Session,
|
||||||
@@ -57,25 +128,44 @@ class PeriodicReportService:
|
|||||||
period_end: datetime | None = None,
|
period_end: datetime | None = None,
|
||||||
generated_by: str = "manual",
|
generated_by: str = "manual",
|
||||||
) -> PeriodicReport:
|
) -> 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:
|
if report_type not in REPORT_TYPE_LABELS:
|
||||||
raise ValueError("不支持的报告类型")
|
raise ValueError("不支持的报告类型")
|
||||||
if period_start is None or period_end is None:
|
period_start = report.period_start
|
||||||
default_start, default_end = PeriodicReportService.default_period(report_type)
|
period_end = report.period_end
|
||||||
period_start = period_start or default_start
|
report.title = _report_title(report_type, period_start, period_end)
|
||||||
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)
|
|
||||||
summaries = _period_summaries(db, user_id=user.id, period_start=period_start, period_end=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))
|
profile = db.scalar(select(UserGrowthProfile).where(UserGrowthProfile.user_id == user.id))
|
||||||
topic_ids = sorted({int(item.topic_session_id) for item in summaries})
|
topic_ids = sorted({int(item.topic_session_id) for item in summaries})
|
||||||
summary_ids = [int(item.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_topic_ids = json.dumps(topic_ids, ensure_ascii=False)
|
||||||
report.source_summary_ids = json.dumps(summary_ids, ensure_ascii=False)
|
report.source_summary_ids = json.dumps(summary_ids, ensure_ascii=False)
|
||||||
report.generated_by = generated_by
|
|
||||||
report.generated_at = _now()
|
report.generated_at = _now()
|
||||||
|
|
||||||
if not summaries:
|
if not summaries:
|
||||||
@@ -83,8 +173,6 @@ class PeriodicReportService:
|
|||||||
report.error_message = None
|
report.error_message = None
|
||||||
report.content = _empty_report_content(report_type=report_type, period_start=period_start, period_end=period_end)
|
report.content = _empty_report_content(report_type=report_type, period_start=period_start, period_end=period_end)
|
||||||
db.add(report)
|
db.add(report)
|
||||||
db.commit()
|
|
||||||
db.refresh(report)
|
|
||||||
return report
|
return report
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -99,8 +187,6 @@ class PeriodicReportService:
|
|||||||
report.error_message = str(exc)[:2000]
|
report.error_message = str(exc)[:2000]
|
||||||
report.content = _fallback_report(summaries)
|
report.content = _fallback_report(summaries)
|
||||||
db.add(report)
|
db.add(report)
|
||||||
db.commit()
|
|
||||||
db.refresh(report)
|
|
||||||
return report
|
return report
|
||||||
|
|
||||||
|
|
||||||
@@ -110,8 +196,8 @@ def periodic_report_dict(report: PeriodicReport) -> dict:
|
|||||||
"userId": report.user_id,
|
"userId": report.user_id,
|
||||||
"reportType": report.report_type,
|
"reportType": report.report_type,
|
||||||
"reportTypeLabel": REPORT_TYPE_LABELS.get(report.report_type, report.report_type),
|
"reportTypeLabel": REPORT_TYPE_LABELS.get(report.report_type, report.report_type),
|
||||||
"periodStart": report.period_start,
|
"periodStart": _local_datetime(report.period_start),
|
||||||
"periodEnd": report.period_end,
|
"periodEnd": _local_datetime(report.period_end),
|
||||||
"title": report.title,
|
"title": report.title,
|
||||||
"content": report.content,
|
"content": report.content,
|
||||||
"sourceSummaryIds": _parse_json_list(report.source_summary_ids),
|
"sourceSummaryIds": _parse_json_list(report.source_summary_ids),
|
||||||
@@ -120,40 +206,119 @@ def periodic_report_dict(report: PeriodicReport) -> dict:
|
|||||||
"status": report.status,
|
"status": report.status,
|
||||||
"errorMessage": report.error_message,
|
"errorMessage": report.error_message,
|
||||||
"generatedBy": report.generated_by,
|
"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,
|
"generatedAt": report.generated_at,
|
||||||
"createdAt": report.created_at,
|
"createdAt": report.created_at,
|
||||||
"updatedAt": report.updated_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,
|
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,
|
user: User,
|
||||||
report_type: ReportType,
|
report_type: ReportType,
|
||||||
period_start: datetime,
|
period_start: datetime,
|
||||||
period_end: datetime,
|
period_end: datetime,
|
||||||
) -> PeriodicReport:
|
) -> PeriodicReport:
|
||||||
report = db.scalar(
|
return PeriodicReport(
|
||||||
select(PeriodicReport).where(
|
|
||||||
PeriodicReport.user_id == user.id,
|
|
||||||
PeriodicReport.report_type == report_type,
|
|
||||||
PeriodicReport.period_start == period_start,
|
|
||||||
PeriodicReport.period_end == 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,
|
user_id=user.id,
|
||||||
report_type=report_type,
|
report_type=report_type,
|
||||||
period_start=period_start,
|
period_start=period_start,
|
||||||
period_end=period_end,
|
period_end=period_end,
|
||||||
title=title,
|
title=_report_title(report_type, period_start, period_end),
|
||||||
)
|
)
|
||||||
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]:
|
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.user_id == user_id,
|
||||||
TopicSummary.status == "success",
|
TopicSummary.status == "success",
|
||||||
TopicSummary.generated_at >= period_start,
|
TopicSummary.generated_at >= period_start,
|
||||||
TopicSummary.generated_at <= period_end,
|
TopicSummary.generated_at < period_end,
|
||||||
)
|
)
|
||||||
.order_by(TopicSummary.generated_at.asc(), TopicSummary.id.asc())
|
.order_by(TopicSummary.generated_at.asc(), TopicSummary.id.asc())
|
||||||
.limit(200)
|
.limit(200)
|
||||||
@@ -181,6 +346,8 @@ def _report_prompt(
|
|||||||
summaries: list[TopicSummary],
|
summaries: list[TopicSummary],
|
||||||
profile: UserGrowthProfile | None,
|
profile: UserGrowthProfile | None,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
local_start = _local_datetime(period_start)
|
||||||
|
local_end = _local_datetime(period_end)
|
||||||
summary_text = "\n\n".join(
|
summary_text = "\n\n".join(
|
||||||
(
|
(
|
||||||
f"主题摘要 {index}\n"
|
f"主题摘要 {index}\n"
|
||||||
@@ -196,7 +363,7 @@ def _report_prompt(
|
|||||||
)
|
)
|
||||||
return (
|
return (
|
||||||
f"请为学员“{user.name or user.nickname or user.phone}”生成一份{REPORT_TYPE_LABELS[report_type]}。\n"
|
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"
|
||||||
"表达方向:回到当下、回到自身、觉察情绪和身体感受,如实释放;建议适度,不要给过多术层面的复杂方案。\n"
|
"表达方向:回到当下、回到自身、觉察情绪和身体感受,如实释放;建议适度,不要给过多术层面的复杂方案。\n"
|
||||||
"请使用 Markdown 输出,结构包含:本周期主要议题、做过或被建议的功课、反复出现的情绪/身体模式、已有变化、下一步观察方向、可以带给老师确认的问题。\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:
|
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 (
|
return (
|
||||||
f"## {REPORT_TYPE_LABELS[report_type]}\n\n"
|
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:
|
def _now() -> datetime:
|
||||||
return datetime.now(UTC).replace(tzinfo=None)
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -58,6 +58,28 @@ def test_default_entitlement_uses_basic_plan_when_user_has_no_assignment():
|
|||||||
assert view.monthly_topic_remaining == 27
|
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():
|
def test_assign_user_plan_replaces_previous_active_plan():
|
||||||
with _db() as db:
|
with _db() as db:
|
||||||
user, _session = _seed_user_session(db)
|
user, _session = _seed_user_session(db)
|
||||||
|
|||||||
@@ -2,16 +2,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
from app.models import Base
|
from app.models import Base
|
||||||
from app.models.ai_config import SystemConfig
|
from app.models.ai_config import SystemConfig
|
||||||
from app.models.chat import ChatSession, TopicSession
|
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.growth import PeriodicReport, TopicSummary, UserGrowthProfile
|
||||||
from app.models.user import User
|
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_service import PeriodicReportService, periodic_report_dict
|
||||||
|
from app.services.periodic_report_worker import PeriodicReportWorker, scheduled_period
|
||||||
|
|
||||||
|
|
||||||
def _db() -> Session:
|
def _db() -> Session:
|
||||||
@@ -66,3 +69,175 @@ def test_generate_empty_periodic_report_when_no_summaries():
|
|||||||
assert report.status == "empty"
|
assert report.status == "empty"
|
||||||
assert "还没有可用于生成报告的主题沉淀" in report.content
|
assert "还没有可用于生成报告的主题沉淀" in report.content
|
||||||
assert periodic_report_dict(report)["sourceSummaryIds"] == []
|
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 "")
|
||||||
|
|||||||
@@ -140,6 +140,29 @@ RESTORE_CONFIRM=YES \
|
|||||||
|
|
||||||
恢复后必须检查迁移版本、`/api/ready`、管理员登录、用户登录和一次真实问答。备份文件与 `CONFIG_ENCRYPTION_KEY` 必须分别保存;只有数据库备份而没有对应加密密钥时,敏感配置无法解密。
|
恢复后必须检查迁移版本、`/api/ready`、管理员登录、用户登录和一次真实问答。备份文件与 `CONFIG_ENCRYPTION_KEY` 必须分别保存;只有数据库备份而没有对应加密密钥时,敏感配置无法解密。
|
||||||
|
|
||||||
|
## 周期报告后台任务
|
||||||
|
|
||||||
|
周期报告使用数据库保存任务状态,Redis 只用于限制多进程并发。服务重启后,等待中的任务会继续执行,超过 30 分钟仍处于执行中的任务会自动恢复并重试。
|
||||||
|
|
||||||
|
默认调度规则:
|
||||||
|
|
||||||
|
- 每周一 02:00(Asia/Shanghai)生成上一自然周周报;
|
||||||
|
- 每月 1 日 03:00(Asia/Shanghai)生成上一自然月月报;
|
||||||
|
- 只处理权益已开启周期报告、账号有效且该周期存在成功主题摘要的用户;
|
||||||
|
- 单个任务最多自动执行 3 次,失败后可在后台用户详情中手动重新生成。
|
||||||
|
|
||||||
|
可通过以下环境变量关闭或调整:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PERIODIC_REPORT_WORKER_ENABLED=true
|
||||||
|
PERIODIC_REPORT_WEEKLY_ENABLED=true
|
||||||
|
PERIODIC_REPORT_MONTHLY_ENABLED=true
|
||||||
|
PERIODIC_REPORT_TIMEZONE=Asia/Shanghai
|
||||||
|
PERIODIC_REPORT_POLL_SECONDS=5
|
||||||
|
PERIODIC_REPORT_STALE_MINUTES=30
|
||||||
|
PERIODIC_REPORT_MAX_ATTEMPTS=3
|
||||||
|
```
|
||||||
|
|
||||||
## 回滚原则
|
## 回滚原则
|
||||||
|
|
||||||
1. 先停止新版本服务。
|
1. 先停止新版本服务。
|
||||||
@@ -164,6 +187,7 @@ RESTORE_CONFIRM=YES \
|
|||||||
- 5 分钟内 HTTP 5xx 比例超过 2%。
|
- 5 分钟内 HTTP 5xx 比例超过 2%。
|
||||||
- `/api/ready` 连续失败。
|
- `/api/ready` 连续失败。
|
||||||
- AI 请求超时或外部服务错误持续增长。
|
- AI 请求超时或外部服务错误持续增长。
|
||||||
|
- 周期报告任务持续失败、长期停留在“生成中”或队列持续积压。
|
||||||
- 问答队列持续接近上限或频繁拒绝请求。
|
- 问答队列持续接近上限或频繁拒绝请求。
|
||||||
- MySQL、Redis 容器重启或磁盘使用率超过 80%。
|
- MySQL、Redis 容器重启或磁盘使用率超过 80%。
|
||||||
- 定时备份任务失败、校验文件缺失或超过 24 小时没有新备份。
|
- 定时备份任务失败、校验文件缺失或超过 24 小时没有新备份。
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ services:
|
|||||||
DB_MAX_OVERFLOW: "20"
|
DB_MAX_OVERFLOW: "20"
|
||||||
DB_POOL_TIMEOUT: "30"
|
DB_POOL_TIMEOUT: "30"
|
||||||
DB_POOL_RECYCLE: "1800"
|
DB_POOL_RECYCLE: "1800"
|
||||||
|
PERIODIC_REPORT_WORKER_ENABLED: "true"
|
||||||
|
PERIODIC_REPORT_WEEKLY_ENABLED: "true"
|
||||||
|
PERIODIC_REPORT_MONTHLY_ENABLED: "true"
|
||||||
|
PERIODIC_REPORT_TIMEZONE: Asia/Shanghai
|
||||||
JWT_SECRET_KEY: local-dev-secret-change-before-production
|
JWT_SECRET_KEY: local-dev-secret-change-before-production
|
||||||
MOCK_SMS_ENABLED: "true"
|
MOCK_SMS_ENABLED: "true"
|
||||||
MOCK_SMS_CODE: "123456"
|
MOCK_SMS_CODE: "123456"
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ services:
|
|||||||
BACKEND_WORKERS: ${BACKEND_WORKERS:-4}
|
BACKEND_WORKERS: ${BACKEND_WORKERS:-4}
|
||||||
DB_POOL_SIZE: ${DB_POOL_SIZE:-20}
|
DB_POOL_SIZE: ${DB_POOL_SIZE:-20}
|
||||||
DB_MAX_OVERFLOW: ${DB_MAX_OVERFLOW:-20}
|
DB_MAX_OVERFLOW: ${DB_MAX_OVERFLOW:-20}
|
||||||
|
PERIODIC_REPORT_WORKER_ENABLED: ${PERIODIC_REPORT_WORKER_ENABLED:-true}
|
||||||
|
PERIODIC_REPORT_POLL_SECONDS: ${PERIODIC_REPORT_POLL_SECONDS:-5}
|
||||||
|
PERIODIC_REPORT_STALE_MINUTES: ${PERIODIC_REPORT_STALE_MINUTES:-30}
|
||||||
|
PERIODIC_REPORT_MAX_ATTEMPTS: ${PERIODIC_REPORT_MAX_ATTEMPTS:-3}
|
||||||
|
PERIODIC_REPORT_WEEKLY_ENABLED: ${PERIODIC_REPORT_WEEKLY_ENABLED:-true}
|
||||||
|
PERIODIC_REPORT_MONTHLY_ENABLED: ${PERIODIC_REPORT_MONTHLY_ENABLED:-true}
|
||||||
|
PERIODIC_REPORT_TIMEZONE: ${PERIODIC_REPORT_TIMEZONE:-Asia/Shanghai}
|
||||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?必须配置 JWT_SECRET_KEY}
|
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?必须配置 JWT_SECRET_KEY}
|
||||||
CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY:?必须配置 CONFIG_ENCRYPTION_KEY}
|
CONFIG_ENCRYPTION_KEY: ${CONFIG_ENCRYPTION_KEY:?必须配置 CONFIG_ENCRYPTION_KEY}
|
||||||
MOCK_SMS_ENABLED: "false"
|
MOCK_SMS_ENABLED: "false"
|
||||||
|
|||||||
@@ -82,8 +82,7 @@ Agent 调用链接入成长档案与权益
|
|||||||
|
|
||||||
- 大本营基础版;
|
- 大本营基础版;
|
||||||
- 五个月深度陪伴版;
|
- 五个月深度陪伴版;
|
||||||
- 高频加购包;
|
- 高频加购包。
|
||||||
- 老师工作版。
|
|
||||||
|
|
||||||
#### 目标
|
#### 目标
|
||||||
|
|
||||||
@@ -100,7 +99,7 @@ Agent 调用链接入成长档案与权益
|
|||||||
权益版本字段建议包含:
|
权益版本字段建议包含:
|
||||||
|
|
||||||
- 名称;
|
- 名称;
|
||||||
- 类型:基础版 / 深度陪伴版 / 高频加购包 / 老师工作版;
|
- 类型:基础版 / 深度陪伴版 / 高频加购包;
|
||||||
- 有效天数;
|
- 有效天数;
|
||||||
- 每月主题会话额度;
|
- 每月主题会话额度;
|
||||||
- 是否开启成长档案;
|
- 是否开启成长档案;
|
||||||
@@ -134,7 +133,6 @@ Agent 调用链接入成长档案与权益
|
|||||||
- 管理员可以给用户分配权益;
|
- 管理员可以给用户分配权益;
|
||||||
- 用户登录后能看到自己的权益状态;
|
- 用户登录后能看到自己的权益状态;
|
||||||
- 聊天接口按权益判断是否可用;
|
- 聊天接口按权益判断是否可用;
|
||||||
- 老师工作版不消耗普通学员额度;
|
|
||||||
- 所有权益变更有审计记录。
|
- 所有权益变更有审计记录。
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -788,6 +786,8 @@ AI 日志增加:
|
|||||||
#### 开发进度
|
#### 开发进度
|
||||||
|
|
||||||
- 2026-07-31:一期已新增周期报告数据表 `sys_periodic_report`,支持周报、月报、阶段总结三种类型;后台用户详情可手动生成/重新生成报告并查看状态、来源主题/摘要、失败原因;用户端“我的实修档案”可查看已生成报告。当前版本先做同步手动生成,后续再接定时/异步任务,聊天链路不会被报告生成阻塞。
|
- 2026-07-31:一期已新增周期报告数据表 `sys_periodic_report`,支持周报、月报、阶段总结三种类型;后台用户详情可手动生成/重新生成报告并查看状态、来源主题/摘要、失败原因;用户端“我的实修档案”可查看已生成报告。当前版本先做同步手动生成,后续再接定时/异步任务,聊天链路不会被报告生成阻塞。
|
||||||
|
- 2026-07-31:报告生成已改为数据库持久化异步任务。管理员点击生成后接口立即返回,后台 Worker 独立生成;支持等待、执行中、成功、空报告、失败状态,最多自动重试 3 次,服务重启后会恢复超时任务。同一用户、类型和周期由唯一约束保证幂等。
|
||||||
|
- 2026-07-31:已接入自然周和自然月定时生成。默认按 `Asia/Shanghai` 在每周一 02:00 生成上一自然周周报、每月 1 日 03:00 生成上一自然月月报;只为权益开启周期报告且本周期存在成功主题摘要的有效用户入队。用户端只显示成功报告,后台可查看任务状态、执行次数、失败原因并手动重新生成。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -831,7 +831,7 @@ AI 日志增加:
|
|||||||
|
|
||||||
- 2026-07-31:二期第一步已在现有统计结果中增加问题分类、关联 AI 请求数、无知识命中次数、请求失败次数、是否需要知识跟进和运营处理建议;后台问题洞察卡片已展示分类标签、无命中/失败标记和建议动作。暂未新增持久化清洗表和人工合并/拆分能力,避免一次性扩大数据模型。
|
- 2026-07-31:二期第一步已在现有统计结果中增加问题分类、关联 AI 请求数、无知识命中次数、请求失败次数、是否需要知识跟进和运营处理建议;后台问题洞察卡片已展示分类标签、无命中/失败标记和建议动作。暂未新增持久化清洗表和人工合并/拆分能力,避免一次性扩大数据模型。
|
||||||
- 2026-07-31:已新增 `sys_question_insight_cleaned_question` 持久化清洗表和清洗版本字段。后台“刷新并统计”只增量处理尚未清洗的用户消息;低价值消息也会写入过滤占位记录,避免后续反复读取聊天原文;统计、筛选和分页均直接读取清洗结果。并发刷新由数据库唯一约束和单消息事务隔离去重,不会重复沉淀同一消息。
|
- 2026-07-31:已新增 `sys_question_insight_cleaned_question` 持久化清洗表和清洗版本字段。后台“刷新并统计”只增量处理尚未清洗的用户消息;低价值消息也会写入过滤占位记录,避免后续反复读取聊天原文;统计、筛选和分页均直接读取清洗结果。并发刷新由数据库唯一约束和单消息事务隔离去重,不会重复沉淀同一消息。
|
||||||
- 待继续:高频问题一键转知识库补充建议、人工合并/拆分问题组,以及清洗规则升级后的版本重建入口。
|
- 暂缓:高频问题一键转知识库补充建议、人工合并/拆分问题组,以及清洗规则升级后的版本重建入口。当前先切换到其他产品板块。
|
||||||
|
|
||||||
#### 验收标准
|
#### 验收标准
|
||||||
|
|
||||||
@@ -842,44 +842,6 @@ AI 日志增加:
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### 14. 老师工作版
|
|
||||||
|
|
||||||
- 优先级:P2
|
|
||||||
- 改动大小:M
|
|
||||||
- 影响范围:用户类型、权限、权益、知识库访问
|
|
||||||
- 依赖:权益版本体系、知识库类型规则
|
|
||||||
|
|
||||||
#### 产品边界
|
|
||||||
|
|
||||||
老师工作版属于千问千答的内部使用场景,但不是师资管理系统。
|
|
||||||
|
|
||||||
不做:
|
|
||||||
|
|
||||||
- 老师收入;
|
|
||||||
- 老师排班;
|
|
||||||
- 老师认证;
|
|
||||||
- 见习流程。
|
|
||||||
|
|
||||||
只做:
|
|
||||||
|
|
||||||
- 老师用 AI 查知识;
|
|
||||||
- 老师用 AI 生成答疑参考;
|
|
||||||
- 老师用 AI 检索固定信息和课程内容。
|
|
||||||
|
|
||||||
#### TODO
|
|
||||||
|
|
||||||
- 增加老师工作版权益;
|
|
||||||
- 老师账号不消耗普通学员额度;
|
|
||||||
- 老师可访问内部允许的知识库;
|
|
||||||
- 老师不能默认查看普通用户隐私;
|
|
||||||
- 可生成答疑参考,但需要提示“请老师自行判断后使用”。
|
|
||||||
|
|
||||||
#### 验收标准
|
|
||||||
|
|
||||||
- 老师账号可使用千问千答;
|
|
||||||
- 老师使用不影响学员额度;
|
|
||||||
- 权限边界清楚。
|
|
||||||
|
|
||||||
## 5. 建议实施批次
|
## 5. 建议实施批次
|
||||||
|
|
||||||
### 第一批:底层产品模型
|
### 第一批:底层产品模型
|
||||||
@@ -925,28 +887,11 @@ AI 日志增加:
|
|||||||
|
|
||||||
1. 周报;
|
1. 周报;
|
||||||
2. 月报;
|
2. 月报;
|
||||||
3. 五个月总结;
|
3. 五个月总结。
|
||||||
4. 老师工作版。
|
|
||||||
|
|
||||||
## 6. 当前最推荐下一步
|
## 6. 当前阶段结论
|
||||||
|
|
||||||
建议下一步不要先做报告、分享稿或老师工作版。
|
- 千问千答不建设老师端或“老师工作版”,老师仍由学员自行联系;
|
||||||
|
- 周报、月报的异步与定时生成完成后,本计划中的连续陪伴主链路先告一段落;
|
||||||
最推荐先做:
|
- 问题洞察的知识库补充建议、人工合并和拆分暂缓;
|
||||||
|
- 下一阶段切换到其他产品板块时,再按对应板块重新确认范围、依赖和验收标准。
|
||||||
```text
|
|
||||||
权益版本体系 → 主题会话机制
|
|
||||||
```
|
|
||||||
|
|
||||||
原因:
|
|
||||||
|
|
||||||
- 这是收费模式的底座;
|
|
||||||
- 会影响用户表、聊天表、额度判断、用户端展示和后台统计;
|
|
||||||
- 后续成长档案、求助卡、分享稿、报告全部依赖它;
|
|
||||||
- 如果后做,会导致前面功能大面积返工。
|
|
||||||
|
|
||||||
如果要进一步降低第一批风险,可以拆成:
|
|
||||||
|
|
||||||
1. 先只建权益版本和用户权益,不立刻改完整扣费;
|
|
||||||
2. 再建主题会话,只做记录不做复杂自动判断;
|
|
||||||
3. 最后把主题会话接入权益扣减和成长档案。
|
|
||||||
|
|||||||
Reference in New Issue
Block a user