feat: auto grant badges for learning milestones

This commit is contained in:
Codex
2026-06-30 14:24:07 +08:00
parent 9299552451
commit 7d2b8fd610
11 changed files with 343 additions and 19 deletions

View File

@@ -13,6 +13,7 @@ import {
} from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { assertAnswerSessionAccess, authorizePracticeSession, recordPracticeAccessEvent } from './access.js';
import { autoGrantBadges, type AutoBadgeGrant } from '../profile/badges.js';
interface QuestionAnswerRow {
question_id: string;
@@ -93,6 +94,11 @@ interface PracticeSessionReportRow {
updatedAt: string;
}
interface PracticeSessionReportWithBadges {
report: PracticeSessionReportRow;
autoBadges: AutoBadgeGrant[];
}
interface PracticeSessionReportAnswerRow {
questionId: string;
selectedOptions: unknown;
@@ -616,6 +622,128 @@ function round4(value: number) {
return Math.round(value * 10000) / 10000;
}
async function totalPracticeReportCount(client: pg.PoolClient, tenantId: string, userId: string) {
const result = await client.query<{ count: string }>(
`
select count(*)::text as count
from public.practice_session_reports
where tenant_id = $1 and user_id = $2
`,
[tenantId, userId],
);
return Number(result.rows[0]?.count || 0);
}
async function bestMockExamScore(client: pg.PoolClient, tenantId: string, userId: string) {
const result = await client.query<{ score: string | number | null }>(
`
select max(score) as score
from public.practice_session_reports
where tenant_id = $1
and user_id = $2
and mode = 'mock_exam'
`,
[tenantId, userId],
);
return finiteNumber(result.rows[0]?.score, 0);
}
async function masteredWordCount(client: pg.PoolClient, tenantId: string, userId: string) {
const result = await client.query<{ count: string }>(
`
select count(*)::text as count
from public.user_word_progress
where tenant_id = $1
and user_id = $2
and status = 'mastered'
`,
[tenantId, userId],
);
return Number(result.rows[0]?.count || 0);
}
async function grantLearningBadgesForPracticeReport(
client: pg.PoolClient,
input: {
tenantId: string;
userId: string;
report: PracticeSessionReportRow;
},
) {
const practiceCount = await totalPracticeReportCount(client, input.tenantId, input.userId);
const grants: AutoBadgeGrant[] = [
...(await autoGrantBadges(client, {
tenantId: input.tenantId,
userId: input.userId,
trigger: 'practice_count',
evidence: {
practiceCount,
practiceReportCount: practiceCount,
latestReportId: input.report.id,
latestPracticeSessionId: input.report.practiceSessionId,
latestPracticeMode: input.report.mode,
latestAnsweredCount: Number(input.report.answeredCount || 0),
latestCorrectCount: Number(input.report.correctCount || 0),
latestAccuracy: finiteNumber(input.report.accuracy, 0),
},
})),
];
if (input.report.mode === 'mock_exam') {
const mockExamScore = finiteNumber(input.report.score, 0);
const mockExamTotalScore = finiteNumber(input.report.totalScore, 0);
grants.push(
...(await autoGrantBadges(client, {
tenantId: input.tenantId,
userId: input.userId,
trigger: 'mock_exam_score',
evidence: {
mockExamScore,
mockExamTotalScore,
mockExamAccuracy: finiteNumber(input.report.accuracy, 0),
mockExamBestScore: await bestMockExamScore(client, input.tenantId, input.userId),
practiceCount,
reportId: input.report.id,
practiceSessionId: input.report.practiceSessionId,
blueprintId: input.report.blueprintId,
collectionId: input.report.collectionId,
},
})),
);
}
return grants;
}
async function grantVocabularyMasteredBadges(
client: pg.PoolClient,
input: {
tenantId: string;
userId: string;
progress: Record<string, unknown>;
source: string;
},
) {
if (input.progress.status !== 'mastered') return [];
const count = await masteredWordCount(client, input.tenantId, input.userId);
return autoGrantBadges(client, {
tenantId: input.tenantId,
userId: input.userId,
trigger: 'vocabulary_mastered',
evidence: {
vocabularyMasteredCount: count,
masteredWordsCount: count,
progressId: input.progress.id,
wordId: input.progress.wordId,
correctCount: input.progress.correctCount,
wrongCount: input.progress.wrongCount,
reviewCount: input.progress.reviewCount,
correctStreak: input.progress.correctStreak,
source: input.source,
},
});
}
function dateParam(ctx: RequestContext, name: string) {
const value = stringParam(ctx, name);
if (!value) return '';
@@ -1547,8 +1675,18 @@ export async function submitPracticeSessionRoute(ctx: RequestContext) {
const userId = await userIdFrom(ctx, body);
const practiceSessionId = requiredString(body, 'practiceSessionId');
const report = await transaction(async client => buildPracticeSessionReport(client, tenantId, userId, practiceSessionId));
return { item: formatReport(report) };
const result = await transaction<PracticeSessionReportWithBadges>(async client => {
const report = await buildPracticeSessionReport(client, tenantId, userId, practiceSessionId);
const autoBadges = await grantLearningBadgesForPracticeReport(client, { tenantId, userId, report });
return { report, autoBadges };
});
return {
item: {
...formatReport(result.report),
...(result.autoBadges.length ? { autoBadges: result.autoBadges } : {}),
},
};
}
export async function practiceSessionReportRoute(ctx: RequestContext) {
@@ -2462,7 +2600,7 @@ export async function reviewWordRoute(ctx: RequestContext) {
[tenantId, userId],
);
return {
const item = {
...progress.rows[0],
review: {
result,
@@ -2470,6 +2608,18 @@ export async function reviewWordRoute(ctx: RequestContext) {
nextReviewDate: schedule.nextReviewDate,
},
};
const autoBadges = await grantVocabularyMasteredBadges(client, {
tenantId,
userId,
progress: item,
source: 'review_word',
});
return {
...item,
...(autoBadges.length ? { autoBadges } : {}),
};
});
return { item };
@@ -2541,7 +2691,18 @@ export async function updateWordProgressRoute(ctx: RequestContext) {
[tenantId, userId],
);
return progress.rows[0];
const item = progress.rows[0];
const autoBadges = await grantVocabularyMasteredBadges(client, {
tenantId,
userId,
progress: item,
source: 'update_word_progress',
});
return {
...item,
...(autoBadges.length ? { autoBadges } : {}),
};
});
return { item };

View File

@@ -3,7 +3,14 @@ import { createUserNotification } from '../notifications/service.js';
type JsonMap = Record<string, unknown>;
export type BadgeTrigger = 'check_in' | 'score' | 'feedback_resolved' | 'activity_reward';
export type BadgeTrigger =
| 'check_in'
| 'score'
| 'feedback_resolved'
| 'activity_reward'
| 'practice_count'
| 'vocabulary_mastered'
| 'mock_exam_score';
export interface BadgeMetricEvidence {
checkInStreak?: number;
@@ -11,6 +18,14 @@ export interface BadgeMetricEvidence {
score?: number;
feedbackResolvedCount?: number;
rewardPoints?: number;
practiceCount?: number;
practiceReportCount?: number;
vocabularyMasteredCount?: number;
masteredWordsCount?: number;
mockExamScore?: number;
mockExamTotalScore?: number;
mockExamAccuracy?: number;
mockExamBestScore?: number;
[key: string]: unknown;
}
@@ -57,6 +72,9 @@ const TRIGGER_UNLOCK_TYPES: Record<BadgeTrigger, string[]> = {
score: ['score'],
feedback_resolved: ['feedback_resolved'],
activity_reward: ['activity_reward'],
practice_count: ['practice_count'],
vocabulary_mastered: ['vocabulary_mastered'],
mock_exam_score: ['mock_exam_score'],
};
const FIELD_ALIASES: Record<string, string[]> = {
@@ -66,6 +84,12 @@ const FIELD_ALIASES: Record<string, string[]> = {
feedback_resolved: ['feedbackResolvedCount', 'feedback.resolvedCount', 'reports.resolvedCount'],
reward_points: ['rewardPoints', 'feedback.rewardPoints'],
activity_reward: ['activityRewardCount', 'activity.rewardCount', 'tasks.claimCount'],
practice_count: ['practiceCount', 'practice.count', 'practice.reportCount', 'practiceReportCount', 'reports.practiceCount'],
vocabulary_mastered: ['vocabularyMasteredCount', 'masteredWordsCount', 'vocabulary.masteredCount', 'words.masteredCount'],
mock_exam_score: ['mockExamScore', 'mockExam.score', 'report.score', 'score'],
mock_exam_total_score: ['mockExamTotalScore', 'mockExam.totalScore', 'report.totalScore'],
mock_exam_accuracy: ['mockExamAccuracy', 'mockExam.accuracy', 'report.accuracy'],
mock_exam_best_score: ['mockExamBestScore', 'mockExam.bestScore', 'reports.mockExamBestScore'],
};
function numberValue(value: unknown) {
@@ -88,6 +112,9 @@ function metricValueForField(field: string | null, evidence: BadgeMetricEvidence
if (trigger === 'score') return numberValue(evidence.score);
if (trigger === 'feedback_resolved') return numberValue(evidence.feedbackResolvedCount);
if (trigger === 'activity_reward') return numberValue(evidence.activityRewardCount);
if (trigger === 'practice_count') return numberValue(evidence.practiceCount ?? evidence.practiceReportCount);
if (trigger === 'vocabulary_mastered') return numberValue(evidence.vocabularyMasteredCount ?? evidence.masteredWordsCount);
if (trigger === 'mock_exam_score') return numberValue(evidence.mockExamScore);
return null;
}
@@ -99,6 +126,12 @@ function metricValueForField(field: string | null, evidence: BadgeMetricEvidence
if (canonical === 'feedback_resolved') return numberValue(evidence.feedbackResolvedCount);
if (canonical === 'reward_points') return numberValue(evidence.rewardPoints);
if (canonical === 'activity_reward') return numberValue(evidence.activityRewardCount);
if (canonical === 'practice_count') return numberValue(evidence.practiceCount ?? evidence.practiceReportCount);
if (canonical === 'vocabulary_mastered') return numberValue(evidence.vocabularyMasteredCount ?? evidence.masteredWordsCount);
if (canonical === 'mock_exam_score') return numberValue(evidence.mockExamScore);
if (canonical === 'mock_exam_total_score') return numberValue(evidence.mockExamTotalScore);
if (canonical === 'mock_exam_accuracy') return numberValue(evidence.mockExamAccuracy);
if (canonical === 'mock_exam_best_score') return numberValue(evidence.mockExamBestScore);
}
}