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
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:
parent
8e5d722a1e
commit
2bfa9ad7c3
@ -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])
|
||||
}
|
||||
|
||||
13
src/common/events/daily-goal-achieved.event.ts
Normal file
13
src/common/events/daily-goal-achieved.event.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
13
src/common/events/streak-updated.event.ts
Normal file
13
src/common/events/streak-updated.event.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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],
|
||||
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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) {
|
||||
|
||||
46
src/modules/review/admin-review.controller.ts
Normal file
46
src/modules/review/admin-review.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
52
src/modules/review/review-card.subscriber.ts
Normal file
52
src/modules/review/review-card.subscriber.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {}
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -88,7 +88,7 @@ export class AiAnalysisWorker extends WorkerHost {
|
||||
knowledgeBaseId: result.knowledgeBaseId || 'unknown',
|
||||
title: w,
|
||||
source: 'ai-analysis',
|
||||
status: 'pending',
|
||||
status: 'open',
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@ -96,6 +96,7 @@ const modelNames = [
|
||||
'chatSession', 'chatMessage', 'chatCitation',
|
||||
'artifact', 'learningGoal', 'streakRecord',
|
||||
'notificationPreference', 'pushToken', 'notificationTemplate',
|
||||
'learningGoal', 'streakRecord', 'learningStats',
|
||||
]
|
||||
|
||||
for (const name of modelNames) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user