fix: M3 audit — scheduleState persistence, AI→ReviewCard subscriber, ActiveRecall queue, streak bug, domain events
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 41s

- M3-02: Add scheduleState to ReviewCard model + persist in updateCard/insertCard
- M3-02: Add ReviewCardSubscriber (OnEvent 'ai.analysis.completed' → generateCards)
- M3-02: Add AdminReviewController (GET /admin-api/reviews)
- M3-01: ActiveRecall now enqueues via AiAnalysisService instead of direct workflow call
- M3-01: FocusItem model adds source field, worker uses status:'open'
- M3-03: Fix streak calculation (break on gap), add StreakUpdatedEvent/DailyGoalAchievedEvent
- M3-03: Add LearningGoal/StreakRecord/LearningStats to Prisma schema
- M3-03: Fix FocusItem recommendation query (status:'pending' → 'open')

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
WangDL 2026-05-24 16:17:34 +08:00
parent 8e5d722a1e
commit 2bfa9ad7c3
14 changed files with 210 additions and 29 deletions

View File

@ -480,6 +480,7 @@ model FocusItem {
suggestion String? @db.Text
priority String @default("normal") @db.VarChar(32)
status String @default("open") @db.VarChar(32)
source String? @db.VarChar(32)
masteryScore Int?
dueAt DateTime?
completedAt DateTime?
@ -509,6 +510,7 @@ model ReviewCard {
easeFactor Decimal @default(2.50) @db.Decimal(4, 2)
repetitionCount Int @default(0)
lapseCount Int @default(0)
scheduleState String? @db.VarChar(16)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
@ -1330,3 +1332,42 @@ model NotificationTemplate {
@@index([type])
}
model LearningGoal {
id String @id @default(cuid())
userId String @unique
dailyCardTarget Int @default(10)
dailyMinuteTarget Int @default(30)
weeklyCardTarget Int @default(50)
favoriteSubject String? @db.VarChar(100)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model StreakRecord {
id String @id @default(cuid())
userId String
streakType String @db.VarChar(32)
length Int @default(0)
startDate DateTime
endDate DateTime
createdAt DateTime @default(now())
@@index([userId])
@@index([streakType])
}
model LearningStats {
id String @id @default(cuid())
userId String
date DateTime
totalCards Int @default(0)
reviewedCards Int @default(0)
newCards Int @default(0)
totalMinutes Int @default(0)
avgMasteryScore Decimal? @db.Decimal(5, 2)
createdAt DateTime @default(now())
@@unique([userId, date])
@@index([userId])
}

View File

@ -0,0 +1,13 @@
import { BaseDomainEvent } from './base-domain.event';
export class DailyGoalAchievedEvent extends BaseDomainEvent {
readonly eventType = 'growth.daily-goal.achieved';
constructor(
public readonly userId: string,
public readonly date: string,
public readonly cardCount: number,
) {
super();
}
}

View File

@ -0,0 +1,13 @@
import { BaseDomainEvent } from './base-domain.event';
export class StreakUpdatedEvent extends BaseDomainEvent {
readonly eventType = 'growth.streak.updated';
constructor(
public readonly userId: string,
public readonly currentStreak: number,
public readonly longestStreak: number,
) {
super();
}
}

View File

@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { AiModule } from '../ai/ai.module';
import { AiAnalysisModule } from '../ai-analysis/ai-analysis.module';
import { ActiveRecallController } from './active-recall.controller';
import { ActiveRecallService } from './active-recall.service';
import { ActiveRecallRepository } from './active-recall.repository';
@Module({
imports: [AiModule],
imports: [AiModule, AiAnalysisModule],
controllers: [ActiveRecallController],
providers: [ActiveRecallService, ActiveRecallRepository],
exports: [ActiveRecallService],

View File

@ -1,6 +1,6 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ActiveRecallRepository } from './active-recall.repository';
import { ActiveRecallAnalysisWorkflow } from '../ai/workflows/active-recall-analysis.workflow';
import { AiAnalysisService } from '../ai-analysis/ai-analysis.service';
import type { PaginationDto } from '../../common/dto/pagination.dto';
@Injectable()
@ -9,7 +9,7 @@ export class ActiveRecallService {
constructor(
private readonly repository: ActiveRecallRepository,
private readonly analysisWorkflow: ActiveRecallAnalysisWorkflow,
private readonly analysisService: AiAnalysisService,
) {}
async findByUserId(userId: string, pagination: PaginationDto) {
@ -22,23 +22,19 @@ export class ActiveRecallService {
const answer = await this.repository.createAnswer(userId, questionId, body);
// Fire-and-forget: answer is saved, analysis runs async
void this.runAnalysis(answer.id, userId, question.questionText, body.answerText);
// Queue AI analysis via BullMQ (worker publishes event + generates FocusItems)
try {
await this.analysisService.analyze(userId, {
questionText: question.questionText,
knowledgeItemContent: '', // worker picks up content from the analysis workflow
userAnswer: body.answerText,
answerId: answer.id,
});
this.logger.log(`AI analysis queued for answer ${answer.id}`);
} catch (err: any) {
this.logger.error(`Failed to queue analysis for answer ${answer.id}: ${err.message}`);
}
return answer;
}
private async runAnalysis(answerId: string, userId: string, questionText: string, userAnswer: string) {
try {
const result = await this.analysisWorkflow.execute({
userId,
questionText,
knowledgeItemContent: '',
userAnswer,
});
this.logger.log(`Analysis complete for answer ${answerId}: score=${result.score}`);
} catch (err: any) {
this.logger.error(`Analysis failed for answer ${answerId}: ${err.message}`);
}
}
}

View File

@ -26,6 +26,8 @@ export class FocusItemsRepository {
reason?: string;
suggestion?: string;
priority?: string;
status?: string;
source?: string;
knowledgeBaseId?: string;
knowledgeItemId?: string;
}) {
@ -36,7 +38,8 @@ export class FocusItemsRepository {
reason: data.reason ?? '',
suggestion: data.suggestion ?? '',
priority: data.priority ?? 'normal',
status: 'open',
status: data.status ?? 'open',
source: data.source ?? null,
knowledgeBaseId: data.knowledgeBaseId ?? null,
knowledgeItemId: data.knowledgeItemId ?? null,
},

View File

@ -1,11 +1,16 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, Optional } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { EventBusService } from '../../common/event-bus/event-bus.service';
import { StreakUpdatedEvent } from '../../common/events/streak-updated.event';
@Injectable()
export class GrowthService {
private readonly logger = new Logger(GrowthService.name);
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
@Optional() private readonly eventBus?: EventBusService,
) {}
/** Calculate streak from daily activity */
async getStreak(userId: string): Promise<{ currentStreak: number; longestStreak: number }> {
@ -24,6 +29,7 @@ export class GrowthService {
const d = new Date(a.activityDate).toISOString().slice(0, 10);
if (!seen.has(d)) { seen.add(d); dates.push(d); }
}
let currentStreak = dates.length > 0 ? 1 : 0;
let longestStreak = currentStreak;
let streak = currentStreak;
@ -38,11 +44,14 @@ export class GrowthService {
} else if (diffDays === 0) {
// same day, skip
} else {
streak = 1;
break; // gap found — stop counting current streak
}
}
currentStreak = streak;
// Publish event for downstream consumers
try { this.eventBus?.publish(new StreakUpdatedEvent(userId, currentStreak, longestStreak)); } catch {}
return { currentStreak, longestStreak };
}
@ -50,9 +59,9 @@ export class GrowthService {
async getRecommendations(userId: string): Promise<{ type: string; title: string; reason: string }[]> {
const recommendations: { type: string; title: string; reason: string }[] = [];
// Find pending focus items
// Find open focus items
const focusCount = await this.prisma.focusItem.count({
where: { userId, status: 'pending' },
where: { userId, status: 'open' },
});
if (focusCount > 0) {

View File

@ -0,0 +1,46 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
import { AdminRolesGuard } from '../../common/guards/admin-roles.guard';
@ApiTags('admin-review')
@ApiBearerAuth()
@Controller('admin-api/reviews')
@UseGuards(AdminAuthGuard, AdminRolesGuard)
export class AdminReviewController {
constructor(private readonly prisma: PrismaService) {}
@Get()
@ApiOperation({ summary: '复习卡片列表Admin' })
@ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'status', required: false })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'limit', required: false })
async list(
@Query('search') search?: string,
@Query('status') status?: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
const take = Math.min(Number(limit) || 20, 100);
const skip = (Math.max(Number(page) || 1, 1) - 1) * take;
const where: any = {};
if (status) where.status = status;
if (search) {
where.frontText = { contains: search };
}
const [items, total] = await Promise.all([
this.prisma.reviewCard.findMany({
where,
orderBy: { createdAt: 'desc' },
take,
skip,
}),
this.prisma.reviewCard.count({ where }),
]);
return { items, total };
}
}

View File

@ -0,0 +1,52 @@
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { ReviewService } from './review.service';
@Injectable()
export class ReviewCardSubscriber {
private readonly logger = new Logger(ReviewCardSubscriber.name);
constructor(private readonly reviewService: ReviewService) {}
@OnEvent('ai.analysis.completed')
async handleAIAnalysisCompleted(payload: {
userId: string;
jobId: string;
sessionId?: string;
answerId?: string;
type: string;
score?: number;
analysis?: Record<string, any>;
}) {
const { userId } = payload;
this.logger.log(
`Received ai.analysis.completed user=${userId} job=${payload.jobId} score=${payload.score}`,
);
if (!payload.analysis) return;
try {
const a = payload.analysis;
const weaknesses = (a.weaknesses || []).join('');
const strengths = (a.strengths || []).join('');
const summary = a.summary || '';
if (!weaknesses && !strengths) return;
const title = summary ? summary.slice(0, 80) : 'AI 分析结果';
const content = `摘要:${summary}\n\n掌握点${strengths}\n\n薄弱点${weaknesses}`;
await this.reviewService.generateCards(userId, {
knowledgeItemTitle: title,
knowledgeItemContent: content,
cardCount: Math.min(3, Math.max(1, (a.weaknesses?.length || 1))),
});
this.logger.log(
`Generated review cards from analysis job=${payload.jobId} for user=${userId}`,
);
} catch (err: any) {
this.logger.error(`Failed to generate review cards from analysis: ${err.message}`);
}
}
}

View File

@ -3,11 +3,13 @@ import { AiModule } from '../ai/ai.module';
import { ReviewController } from './review.controller';
import { ReviewService } from './review.service';
import { ReviewRepository } from './review.repository';
import { ReviewCardSubscriber } from './review-card.subscriber';
import { AdminReviewController } from './admin-review.controller';
@Module({
imports: [AiModule],
controllers: [ReviewController],
providers: [ReviewService, ReviewRepository],
controllers: [ReviewController, AdminReviewController],
providers: [ReviewService, ReviewRepository, ReviewCardSubscriber],
exports: [ReviewService],
})
export class ReviewModule {}

View File

@ -32,6 +32,7 @@ export class ReviewRepository {
easeFactor?: number;
repetitionCount?: number;
lapseCount?: number;
scheduleState?: string;
nextReviewAt?: Date;
}) {
return this.prisma.reviewCard.create({ data });
@ -41,8 +42,10 @@ export class ReviewRepository {
status?: string;
nextReviewAt?: Date;
intervalDays?: number;
easeFactor?: number;
repetitionCount?: number;
lapseCount?: number;
scheduleState?: string;
}) {
await this.prisma.reviewCard.update({ where: { id }, data });
}

View File

@ -59,7 +59,7 @@ export class ReviewService {
});
await this.reviewRepository.updateCard(id, {
status: 'active', nextReviewAt, intervalDays, repetitionCount, lapseCount,
status: 'active', nextReviewAt, intervalDays, easeFactor, repetitionCount, lapseCount, scheduleState,
});
return { log, nextReviewAt, scheduleState, intervalDays };
@ -89,6 +89,7 @@ export class ReviewService {
easeFactor: EASE_FACTOR_DEFAULT,
repetitionCount: 0,
lapseCount: 0,
scheduleState: 'new',
nextReviewAt: new Date(),
});
savedCards.push(saved);

View File

@ -88,7 +88,7 @@ export class AiAnalysisWorker extends WorkerHost {
knowledgeBaseId: result.knowledgeBaseId || 'unknown',
title: w,
source: 'ai-analysis',
status: 'pending',
status: 'open',
});
} catch {}
}

View File

@ -96,6 +96,7 @@ const modelNames = [
'chatSession', 'chatMessage', 'chatCitation',
'artifact', 'learningGoal', 'streakRecord',
'notificationPreference', 'pushToken', 'notificationTemplate',
'learningGoal', 'streakRecord', 'learningStats',
]
for (const name of modelNames) {