From 098b8055f53fc16e144459f2451c457847435d68 Mon Sep 17 00:00:00 2001 From: WangDL Date: Sun, 24 May 2026 14:16:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20M3-03=20=E2=80=94=20Growth=20&=20Retent?= =?UTF-8?q?ion,=20streak=20+=20recommendations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GrowthService: streak calculation from DailyLearningActivity - Recommendations: focus items, due review cards, new knowledge items - New API: GET /api/activity/streak, GET /api/activity/recommendations Co-Authored-By: Claude Opus 4.7 --- .../learning-activity/growth.service.ts | 90 +++++++++++++++++++ .../learning-activity.controller.ts | 28 ++++-- .../learning-activity.module.ts | 5 +- test/m3.e2e-spec.ts | 18 ++++ 4 files changed, 130 insertions(+), 11 deletions(-) create mode 100644 src/modules/learning-activity/growth.service.ts diff --git a/src/modules/learning-activity/growth.service.ts b/src/modules/learning-activity/growth.service.ts new file mode 100644 index 0000000..eac4ff4 --- /dev/null +++ b/src/modules/learning-activity/growth.service.ts @@ -0,0 +1,90 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +@Injectable() +export class GrowthService { + private readonly logger = new Logger(GrowthService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** Calculate streak from daily activity */ + async getStreak(userId: string): Promise<{ currentStreak: number; longestStreak: number }> { + const activities = await this.prisma.dailyLearningActivity.findMany({ + where: { userId }, + orderBy: { date: 'desc' }, + select: { date: true }, + distinct: ['date'], + take: 365, + }); + + if (activities.length === 0) return { currentStreak: 0, longestStreak: 0 }; + + const dates = activities.map(a => new Date(a.date).toISOString().slice(0, 10)); + let currentStreak = 1; + let longestStreak = 1; + let streak = 1; + + for (let i = 1; i < dates.length; i++) { + const prev = new Date(dates[i - 1]); + const curr = new Date(dates[i]); + const diffDays = Math.round((prev.getTime() - curr.getTime()) / 86400000); + if (diffDays === 1) { + streak++; + longestStreak = Math.max(longestStreak, streak); + } else if (diffDays === 0) { + // same day, skip + } else { + streak = 1; + } + } + currentStreak = streak; + + return { currentStreak, longestStreak }; + } + + /** Get learning recommendation based on mastery */ + async getRecommendations(userId: string): Promise<{ type: string; title: string; reason: string }[]> { + const recommendations: { type: string; title: string; reason: string }[] = []; + + // Find pending focus items + const focusCount = await this.prisma.focusItem.count({ + where: { userId, status: 'pending' }, + }); + + if (focusCount > 0) { + recommendations.push({ + type: 'focus', title: `${focusCount} 个待巩固项`, reason: '巩固薄弱环节,提升掌握度', + }); + } + + // Find due review cards + const now = new Date(); + const dueCards = await this.prisma.reviewCard.count({ + where: { userId, nextReviewAt: { lte: now }, status: 'active' }, + }); + + if (dueCards > 0) { + recommendations.push({ + type: 'review', title: `${dueCards} 张复习卡到期`, reason: '间隔复习,巩固长期记忆', + }); + } + + // Suggest new knowledge items if no pending items + if (focusCount === 0 && dueCards === 0) { + const itemsCount = await this.prisma.knowledgeItem.count({ + where: { userId, deletedAt: null, learnable: true }, + }); + if (itemsCount > 0) { + recommendations.push({ + type: 'learn', title: `${itemsCount} 个可学习知识点`, reason: '继续拓展知识库', + }); + } else { + recommendations.push({ + type: 'import', title: '导入新资料', reason: '开始学习新内容', + }); + } + } + + return recommendations; + } +} diff --git a/src/modules/learning-activity/learning-activity.controller.ts b/src/modules/learning-activity/learning-activity.controller.ts index 0c1d761..2b5b07c 100644 --- a/src/modules/learning-activity/learning-activity.controller.ts +++ b/src/modules/learning-activity/learning-activity.controller.ts @@ -1,13 +1,17 @@ import { Controller, Get, Query } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { LearningActivityService } from './learning-activity.service'; +import { GrowthService } from './growth.service'; import { CurrentUser } from '../../common/decorators/current-user.decorator'; import type { UserPayload } from '../../common/types'; @ApiTags('learning-activity') @Controller('activity') export class LearningActivityController { - constructor(private readonly activityService: LearningActivityService) {} + constructor( + private readonly activityService: LearningActivityService, + private readonly growth: GrowthService, + ) {} @Get('heatmap') @ApiOperation({ summary: '获取学习热力图数据' }) @@ -23,14 +27,20 @@ export class LearningActivityController { @Get('trend') @ApiOperation({ summary: '获取 AI 学习趋势分析' }) - async getTrend( - @CurrentUser() user: UserPayload, - @Query('days') days?: string, - ) { + async getTrend(@CurrentUser() user: UserPayload, @Query('days') days?: string) { const periodDays = parseInt(days || '7', 10); - return this.activityService.getTrend( - String(user?.id || 'anonymous'), - Math.min(Math.max(periodDays, 7), 30), - ); + return this.activityService.getTrend(String(user?.id || 'anonymous'), Math.min(Math.max(periodDays, 7), 30)); + } + + @Get('streak') + @ApiOperation({ summary: '获取连续学习天数' }) + async getStreak(@CurrentUser() user: UserPayload) { + return this.growth.getStreak(String(user?.id || 'anonymous')); + } + + @Get('recommendations') + @ApiOperation({ summary: '获取下一步学习推荐' }) + async getRecommendations(@CurrentUser() user: UserPayload) { + return this.growth.getRecommendations(String(user?.id || 'anonymous')); } } diff --git a/src/modules/learning-activity/learning-activity.module.ts b/src/modules/learning-activity/learning-activity.module.ts index 996e212..39faaf3 100644 --- a/src/modules/learning-activity/learning-activity.module.ts +++ b/src/modules/learning-activity/learning-activity.module.ts @@ -3,11 +3,12 @@ import { AiModule } from '../ai/ai.module'; import { LearningActivityController } from './learning-activity.controller'; import { LearningActivityService } from './learning-activity.service'; import { LearningActivityRepository } from './learning-activity.repository'; +import { GrowthService } from './growth.service'; @Module({ imports: [AiModule], controllers: [LearningActivityController], - providers: [LearningActivityService, LearningActivityRepository], - exports: [LearningActivityService], + providers: [LearningActivityService, LearningActivityRepository, GrowthService], + exports: [LearningActivityService, GrowthService], }) export class LearningActivityModule {} diff --git a/test/m3.e2e-spec.ts b/test/m3.e2e-spec.ts index ba3e539..7cb002a 100644 --- a/test/m3.e2e-spec.ts +++ b/test/m3.e2e-spec.ts @@ -66,4 +66,22 @@ describe('M3 E2E Tests', () => { expect(res.body.success).toBe(true); }); }); + + // ══════════════════════════════════════════════ + // M3-03: Growth & Retention + // ══════════════════════════════════════════════ + describe('M3-03 Growth & Retention', () => { + it('GET /api/activity/streak → 401 without token', async () => { + await request(app.getHttpServer()).get('/api/activity/streak').expect(401); + }); + + it('GET /api/activity/recommendations → endpoint exists', async () => { + await request(app.getHttpServer()).get('/api/activity/recommendations').expect(401); + }); + + it('GrowthService registered (app starts cleanly)', async () => { + const res = await request(app.getHttpServer()).get('/api').expect(200); + expect(res.body.success).toBe(true); + }); + }); });