feat: M3-03 — Growth & Retention, streak + recommendations
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 19s

- 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 <noreply@anthropic.com>
This commit is contained in:
WangDL 2026-05-24 14:16:14 +08:00
parent cddcf57a93
commit 098b8055f5
4 changed files with 130 additions and 11 deletions

View File

@ -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;
}
}

View File

@ -1,13 +1,17 @@
import { Controller, Get, Query } from '@nestjs/common'; import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { LearningActivityService } from './learning-activity.service'; import { LearningActivityService } from './learning-activity.service';
import { GrowthService } from './growth.service';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { UserPayload } from '../../common/types'; import type { UserPayload } from '../../common/types';
@ApiTags('learning-activity') @ApiTags('learning-activity')
@Controller('activity') @Controller('activity')
export class LearningActivityController { export class LearningActivityController {
constructor(private readonly activityService: LearningActivityService) {} constructor(
private readonly activityService: LearningActivityService,
private readonly growth: GrowthService,
) {}
@Get('heatmap') @Get('heatmap')
@ApiOperation({ summary: '获取学习热力图数据' }) @ApiOperation({ summary: '获取学习热力图数据' })
@ -23,14 +27,20 @@ export class LearningActivityController {
@Get('trend') @Get('trend')
@ApiOperation({ summary: '获取 AI 学习趋势分析' }) @ApiOperation({ summary: '获取 AI 学习趋势分析' })
async getTrend( async getTrend(@CurrentUser() user: UserPayload, @Query('days') days?: string) {
@CurrentUser() user: UserPayload,
@Query('days') days?: string,
) {
const periodDays = parseInt(days || '7', 10); const periodDays = parseInt(days || '7', 10);
return this.activityService.getTrend( return this.activityService.getTrend(String(user?.id || 'anonymous'), Math.min(Math.max(periodDays, 7), 30));
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'));
} }
} }

View File

@ -3,11 +3,12 @@ import { AiModule } from '../ai/ai.module';
import { LearningActivityController } from './learning-activity.controller'; import { LearningActivityController } from './learning-activity.controller';
import { LearningActivityService } from './learning-activity.service'; import { LearningActivityService } from './learning-activity.service';
import { LearningActivityRepository } from './learning-activity.repository'; import { LearningActivityRepository } from './learning-activity.repository';
import { GrowthService } from './growth.service';
@Module({ @Module({
imports: [AiModule], imports: [AiModule],
controllers: [LearningActivityController], controllers: [LearningActivityController],
providers: [LearningActivityService, LearningActivityRepository], providers: [LearningActivityService, LearningActivityRepository, GrowthService],
exports: [LearningActivityService], exports: [LearningActivityService, GrowthService],
}) })
export class LearningActivityModule {} export class LearningActivityModule {}

View File

@ -66,4 +66,22 @@ describe('M3 E2E Tests', () => {
expect(res.body.success).toBe(true); 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);
});
});
}); });