diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f364564..139edc5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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]) +} diff --git a/src/common/events/daily-goal-achieved.event.ts b/src/common/events/daily-goal-achieved.event.ts new file mode 100644 index 0000000..eb575c2 --- /dev/null +++ b/src/common/events/daily-goal-achieved.event.ts @@ -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(); + } +} diff --git a/src/common/events/streak-updated.event.ts b/src/common/events/streak-updated.event.ts new file mode 100644 index 0000000..aa69dac --- /dev/null +++ b/src/common/events/streak-updated.event.ts @@ -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(); + } +} diff --git a/src/modules/active-recall/active-recall.module.ts b/src/modules/active-recall/active-recall.module.ts index 48960d4..4240e76 100644 --- a/src/modules/active-recall/active-recall.module.ts +++ b/src/modules/active-recall/active-recall.module.ts @@ -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], diff --git a/src/modules/active-recall/active-recall.service.ts b/src/modules/active-recall/active-recall.service.ts index c0caad3..22e03f9 100644 --- a/src/modules/active-recall/active-recall.service.ts +++ b/src/modules/active-recall/active-recall.service.ts @@ -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}`); - } - } } diff --git a/src/modules/focus-items/focus-items.repository.ts b/src/modules/focus-items/focus-items.repository.ts index 251d72b..5d18e0e 100644 --- a/src/modules/focus-items/focus-items.repository.ts +++ b/src/modules/focus-items/focus-items.repository.ts @@ -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, }, diff --git a/src/modules/learning-activity/growth.service.ts b/src/modules/learning-activity/growth.service.ts index ac7b753..cb59705 100644 --- a/src/modules/learning-activity/growth.service.ts +++ b/src/modules/learning-activity/growth.service.ts @@ -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) { diff --git a/src/modules/review/admin-review.controller.ts b/src/modules/review/admin-review.controller.ts new file mode 100644 index 0000000..62e3678 --- /dev/null +++ b/src/modules/review/admin-review.controller.ts @@ -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 }; + } +} diff --git a/src/modules/review/review-card.subscriber.ts b/src/modules/review/review-card.subscriber.ts new file mode 100644 index 0000000..53ad1ec --- /dev/null +++ b/src/modules/review/review-card.subscriber.ts @@ -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; + }) { + 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}`); + } + } +} diff --git a/src/modules/review/review.module.ts b/src/modules/review/review.module.ts index 05528b4..f790eb4 100644 --- a/src/modules/review/review.module.ts +++ b/src/modules/review/review.module.ts @@ -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 {} diff --git a/src/modules/review/review.repository.ts b/src/modules/review/review.repository.ts index 6f8e2bb..ba9f268 100644 --- a/src/modules/review/review.repository.ts +++ b/src/modules/review/review.repository.ts @@ -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 }); } diff --git a/src/modules/review/review.service.ts b/src/modules/review/review.service.ts index 7ebebfe..9c848d0 100644 --- a/src/modules/review/review.service.ts +++ b/src/modules/review/review.service.ts @@ -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); diff --git a/src/workers/ai-analysis.worker.ts b/src/workers/ai-analysis.worker.ts index 05885e0..892ee21 100644 --- a/src/workers/ai-analysis.worker.ts +++ b/src/workers/ai-analysis.worker.ts @@ -88,7 +88,7 @@ export class AiAnalysisWorker extends WorkerHost { knowledgeBaseId: result.knowledgeBaseId || 'unknown', title: w, source: 'ai-analysis', - status: 'pending', + status: 'open', }); } catch {} } diff --git a/test/mocks/prisma.mock.ts b/test/mocks/prisma.mock.ts index f38f6dc..2b23f4b 100644 --- a/test/mocks/prisma.mock.ts +++ b/test/mocks/prisma.mock.ts @@ -96,6 +96,7 @@ const modelNames = [ 'chatSession', 'chatMessage', 'chatCitation', 'artifact', 'learningGoal', 'streakRecord', 'notificationPreference', 'pushToken', 'notificationTemplate', + 'learningGoal', 'streakRecord', 'learningStats', ] for (const name of modelNames) {