feat: M3-03 — Growth & Retention, streak + recommendations
Some checks failed
Deploy API Server / build-and-deploy (push) Failing after 19s
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:
parent
cddcf57a93
commit
098b8055f5
90
src/modules/learning-activity/growth.service.ts
Normal file
90
src/modules/learning-activity/growth.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {}
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user