forked from wangziqi/gongxue-base
feat: add learning stats and review plans
This commit is contained in:
@@ -3,7 +3,10 @@ import {
|
|||||||
createPracticeSessionRoute,
|
createPracticeSessionRoute,
|
||||||
favoriteQuestionsRoute,
|
favoriteQuestionsRoute,
|
||||||
favoriteWordsRoute,
|
favoriteWordsRoute,
|
||||||
|
learningStatsRoute,
|
||||||
|
learningTrendRoute,
|
||||||
practiceReportsRoute,
|
practiceReportsRoute,
|
||||||
|
practiceHistoryRoute,
|
||||||
practiceSessionReportRoute,
|
practiceSessionReportRoute,
|
||||||
resolveWrongQuestionRoute,
|
resolveWrongQuestionRoute,
|
||||||
submitAnswerRoute,
|
submitAnswerRoute,
|
||||||
@@ -13,6 +16,7 @@ import {
|
|||||||
updateWordProgressRoute,
|
updateWordProgressRoute,
|
||||||
wordProgressRoute,
|
wordProgressRoute,
|
||||||
wordStatsRoute,
|
wordStatsRoute,
|
||||||
|
wrongQuestionReviewPlanRoute,
|
||||||
wrongQuestionsRoute,
|
wrongQuestionsRoute,
|
||||||
} from './routes.js';
|
} from './routes.js';
|
||||||
|
|
||||||
@@ -20,11 +24,15 @@ export const learningRoutes: RouteDefinition[] = [
|
|||||||
['POST', '/api/learning/practice-sessions', createPracticeSessionRoute],
|
['POST', '/api/learning/practice-sessions', createPracticeSessionRoute],
|
||||||
['POST', '/api/learning/practice-sessions/submit', submitPracticeSessionRoute],
|
['POST', '/api/learning/practice-sessions/submit', submitPracticeSessionRoute],
|
||||||
['GET', '/api/learning/practice-sessions/report', practiceSessionReportRoute],
|
['GET', '/api/learning/practice-sessions/report', practiceSessionReportRoute],
|
||||||
|
['GET', '/api/learning/practice-sessions/history', practiceHistoryRoute],
|
||||||
['GET', '/api/learning/practice-reports', practiceReportsRoute],
|
['GET', '/api/learning/practice-reports', practiceReportsRoute],
|
||||||
|
['GET', '/api/learning/stats', learningStatsRoute],
|
||||||
|
['GET', '/api/learning/trend', learningTrendRoute],
|
||||||
['POST', '/api/learning/answers', submitAnswerRoute],
|
['POST', '/api/learning/answers', submitAnswerRoute],
|
||||||
['GET', '/api/learning/favorites/questions', favoriteQuestionsRoute],
|
['GET', '/api/learning/favorites/questions', favoriteQuestionsRoute],
|
||||||
['POST', '/api/learning/favorites/questions', toggleFavoriteQuestionRoute],
|
['POST', '/api/learning/favorites/questions', toggleFavoriteQuestionRoute],
|
||||||
['GET', '/api/learning/wrong-questions', wrongQuestionsRoute],
|
['GET', '/api/learning/wrong-questions', wrongQuestionsRoute],
|
||||||
|
['GET', '/api/learning/wrong-questions/review-plan', wrongQuestionReviewPlanRoute],
|
||||||
['POST', '/api/learning/wrong-questions/resolve', resolveWrongQuestionRoute],
|
['POST', '/api/learning/wrong-questions/resolve', resolveWrongQuestionRoute],
|
||||||
['GET', '/api/learning/vocabulary/progress', wordProgressRoute],
|
['GET', '/api/learning/vocabulary/progress', wordProgressRoute],
|
||||||
['POST', '/api/learning/vocabulary/progress', updateWordProgressRoute],
|
['POST', '/api/learning/vocabulary/progress', updateWordProgressRoute],
|
||||||
|
|||||||
@@ -160,6 +160,18 @@ interface PracticeAssembly {
|
|||||||
rules: Record<string, unknown>;
|
rules: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TrendMetric = {
|
||||||
|
date: string;
|
||||||
|
answeredCount: number;
|
||||||
|
correctCount: number;
|
||||||
|
wrongCount: number;
|
||||||
|
accuracy: number;
|
||||||
|
sessionCount: number;
|
||||||
|
reportCount: number;
|
||||||
|
score: number;
|
||||||
|
totalScore: number;
|
||||||
|
};
|
||||||
|
|
||||||
function normalizeStringArray(value: unknown): string[] {
|
function normalizeStringArray(value: unknown): string[] {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
return value.map(item => String(item)).filter(item => item !== '');
|
return value.map(item => String(item)).filter(item => item !== '');
|
||||||
@@ -234,6 +246,15 @@ function round4(value: number) {
|
|||||||
return Math.round(value * 10000) / 10000;
|
return Math.round(value * 10000) / 10000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dateParam(ctx: RequestContext, name: string) {
|
||||||
|
const value = stringParam(ctx, name);
|
||||||
|
if (!value) return '';
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||||
|
throw new HttpError(400, `${name} must be YYYY-MM-DD`, 'INVALID_DATE_PARAM');
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function idArrayFromJson(value: unknown): string[] {
|
function idArrayFromJson(value: unknown): string[] {
|
||||||
return normalizeStringArray(value);
|
return normalizeStringArray(value);
|
||||||
}
|
}
|
||||||
@@ -465,6 +486,41 @@ async function collectFromLegacyTarget(
|
|||||||
return rows.map(row => row.id);
|
return rows.map(row => row.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function collectWrongReviewQuestions(tenantId: string, userId: string, limit: number) {
|
||||||
|
const rows = await query<{ id: string }>(
|
||||||
|
`
|
||||||
|
select q.id
|
||||||
|
from public.wrong_questions wq
|
||||||
|
join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id
|
||||||
|
where wq.tenant_id = $1
|
||||||
|
and wq.user_id = $2
|
||||||
|
and wq.resolved_at is null
|
||||||
|
and q.status = 'published'
|
||||||
|
order by wq.wrong_count desc, wq.last_wrong_at asc
|
||||||
|
limit $3
|
||||||
|
`,
|
||||||
|
[tenantId, userId, limit],
|
||||||
|
);
|
||||||
|
return rows.map(row => row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectFavoriteReviewQuestions(tenantId: string, userId: string, limit: number) {
|
||||||
|
const rows = await query<{ id: string }>(
|
||||||
|
`
|
||||||
|
select q.id
|
||||||
|
from public.favorite_questions fq
|
||||||
|
join public.questions q on q.id = fq.question_id and q.tenant_id = fq.tenant_id
|
||||||
|
where fq.tenant_id = $1
|
||||||
|
and fq.user_id = $2
|
||||||
|
and q.status = 'published'
|
||||||
|
order by fq.created_at desc
|
||||||
|
limit $3
|
||||||
|
`,
|
||||||
|
[tenantId, userId, limit],
|
||||||
|
);
|
||||||
|
return rows.map(row => row.id);
|
||||||
|
}
|
||||||
|
|
||||||
function sectionType(section: unknown) {
|
function sectionType(section: unknown) {
|
||||||
if (!section || typeof section !== 'object' || Array.isArray(section)) return null;
|
if (!section || typeof section !== 'object' || Array.isArray(section)) return null;
|
||||||
const object = section as Record<string, unknown>;
|
const object = section as Record<string, unknown>;
|
||||||
@@ -485,7 +541,7 @@ function sectionLimit(section: unknown, fallback: number) {
|
|||||||
return positiveInt(object.questionCount ?? object.limit, fallback);
|
return positiveInt(object.questionCount ?? object.limit, fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assembleQuestionIds(tenantId: string, assembly: PracticeAssembly) {
|
async function assembleQuestionIds(tenantId: string, userId: string, assembly: PracticeAssembly) {
|
||||||
const randomize = assembly.mode === 'random' || assembly.mode === 'mock_exam' || assembly.rules.randomize === true;
|
const randomize = assembly.mode === 'random' || assembly.mode === 'mock_exam' || assembly.rules.randomize === true;
|
||||||
const questionIds: string[] = [];
|
const questionIds: string[] = [];
|
||||||
const pushUnique = (ids: string[]) => {
|
const pushUnique = (ids: string[]) => {
|
||||||
@@ -494,6 +550,13 @@ async function assembleQuestionIds(tenantId: string, assembly: PracticeAssembly)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (assembly.mode === 'wrong_review') {
|
||||||
|
return collectWrongReviewQuestions(tenantId, userId, assembly.questionLimit);
|
||||||
|
}
|
||||||
|
if (assembly.mode === 'favorite_review') {
|
||||||
|
return collectFavoriteReviewQuestions(tenantId, userId, assembly.questionLimit);
|
||||||
|
}
|
||||||
|
|
||||||
if (assembly.sections.length && (assembly.collectionId || assembly.contentNodeId)) {
|
if (assembly.sections.length && (assembly.collectionId || assembly.contentNodeId)) {
|
||||||
for (const section of assembly.sections) {
|
for (const section of assembly.sections) {
|
||||||
const limit = sectionLimit(section, assembly.questionLimit);
|
const limit = sectionLimit(section, assembly.questionLimit);
|
||||||
@@ -521,11 +584,17 @@ export async function createPracticeSessionRoute(ctx: RequestContext) {
|
|||||||
const tenantId = await tenantIdFrom(ctx);
|
const tenantId = await tenantIdFrom(ctx);
|
||||||
const userId = await userIdFrom(ctx, body);
|
const userId = await userIdFrom(ctx, body);
|
||||||
const assembly = await buildPracticeAssembly(tenantId, body);
|
const assembly = await buildPracticeAssembly(tenantId, body);
|
||||||
const questionIds = await assembleQuestionIds(tenantId, assembly);
|
const questionIds = await assembleQuestionIds(tenantId, userId, assembly);
|
||||||
|
|
||||||
if ((assembly.blueprintId || assembly.collectionId || assembly.contentNodeId) && questionIds.length === 0) {
|
if ((assembly.blueprintId || assembly.collectionId || assembly.contentNodeId) && questionIds.length === 0) {
|
||||||
throw new HttpError(409, 'No published questions are available for this practice target', 'NO_PRACTICE_QUESTIONS');
|
throw new HttpError(409, 'No published questions are available for this practice target', 'NO_PRACTICE_QUESTIONS');
|
||||||
}
|
}
|
||||||
|
if (assembly.mode === 'wrong_review' && questionIds.length === 0) {
|
||||||
|
throw new HttpError(409, 'No unresolved wrong questions are available for review', 'NO_WRONG_REVIEW_QUESTIONS');
|
||||||
|
}
|
||||||
|
if (assembly.mode === 'favorite_review' && questionIds.length === 0) {
|
||||||
|
throw new HttpError(409, 'No favorite questions are available for review', 'NO_FAVORITE_REVIEW_QUESTIONS');
|
||||||
|
}
|
||||||
|
|
||||||
const item = await transaction(async client => {
|
const item = await transaction(async client => {
|
||||||
const access = await authorizePracticeSession(client, {
|
const access = await authorizePracticeSession(client, {
|
||||||
@@ -1110,6 +1179,370 @@ export async function practiceReportsRoute(ctx: RequestContext) {
|
|||||||
return { items: items.map(formatReport) };
|
return { items: items.map(formatReport) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function practiceHistoryRoute(ctx: RequestContext) {
|
||||||
|
const tenantId = await tenantIdFrom(ctx);
|
||||||
|
const userId = await userIdFrom(ctx);
|
||||||
|
const mode = stringParam(ctx, 'mode');
|
||||||
|
const status = stringParam(ctx, 'status');
|
||||||
|
const limit = intParam(ctx, 'limit', 50, 200);
|
||||||
|
|
||||||
|
const items = await query(
|
||||||
|
`
|
||||||
|
select ps.id, ps.mode, ps.target_type as "targetType", ps.target_id as "targetId",
|
||||||
|
ps.blueprint_id as "blueprintId", pb.name as "blueprintName",
|
||||||
|
ps.collection_id as "collectionId", qc.name as "collectionName",
|
||||||
|
ps.entry_id as "entryId", ce.name as "entryName",
|
||||||
|
ps.content_node_id as "contentNodeId", cn.name as "contentNodeName",
|
||||||
|
ps.question_count as "questionCount", ps.duration_minutes as "durationMinutes",
|
||||||
|
ps.total_score as "sessionTotalScore", ps.access_mode as "accessMode",
|
||||||
|
ps.started_at as "startedAt", ps.finished_at as "finishedAt",
|
||||||
|
ps.expires_at as "expiresAt",
|
||||||
|
case
|
||||||
|
when ps.finished_at is not null then 'finished'
|
||||||
|
when ps.expires_at is not null and ps.expires_at <= now() then 'expired'
|
||||||
|
else 'active'
|
||||||
|
end as status,
|
||||||
|
coalesce(ar.answered_count, 0)::integer as "answeredCount",
|
||||||
|
coalesce(ar.correct_count, 0)::integer as "correctCount",
|
||||||
|
coalesce(ar.wrong_count, 0)::integer as "wrongCount",
|
||||||
|
r.id as "reportId", r.score, r.total_score as "reportTotalScore",
|
||||||
|
r.accuracy, r.submitted_at as "submittedAt"
|
||||||
|
from public.practice_sessions ps
|
||||||
|
left join public.practice_blueprints pb on pb.id = ps.blueprint_id and pb.tenant_id = ps.tenant_id
|
||||||
|
left join public.question_collections qc on qc.id = ps.collection_id and qc.tenant_id = ps.tenant_id
|
||||||
|
left join public.content_entries ce on ce.id = ps.entry_id and ce.tenant_id = ps.tenant_id
|
||||||
|
left join public.content_nodes cn on cn.id = ps.content_node_id and cn.tenant_id = ps.tenant_id
|
||||||
|
left join public.practice_session_reports r on r.practice_session_id = ps.id and r.tenant_id = ps.tenant_id and r.user_id = ps.user_id
|
||||||
|
left join lateral (
|
||||||
|
select count(distinct question_id)::integer as answered_count,
|
||||||
|
count(distinct question_id) filter (where is_correct is true)::integer as correct_count,
|
||||||
|
count(distinct question_id) filter (where is_correct is false)::integer as wrong_count
|
||||||
|
from public.answer_records
|
||||||
|
where tenant_id = ps.tenant_id
|
||||||
|
and user_id = ps.user_id
|
||||||
|
and practice_session_id = ps.id
|
||||||
|
) ar on true
|
||||||
|
where ps.tenant_id = $1 and ps.user_id = $2
|
||||||
|
and ($3::text = '' or ps.mode = $3::text)
|
||||||
|
and (
|
||||||
|
$4::text = ''
|
||||||
|
or ($4::text = 'finished' and ps.finished_at is not null)
|
||||||
|
or ($4::text = 'active' and ps.finished_at is null and (ps.expires_at is null or ps.expires_at > now()))
|
||||||
|
or ($4::text = 'expired' and ps.finished_at is null and ps.expires_at is not null and ps.expires_at <= now())
|
||||||
|
)
|
||||||
|
order by coalesce(ps.finished_at, ps.started_at) desc
|
||||||
|
limit $5
|
||||||
|
`,
|
||||||
|
[tenantId, userId, mode, status, limit],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function learningStatsRoute(ctx: RequestContext) {
|
||||||
|
const tenantId = await tenantIdFrom(ctx);
|
||||||
|
const userId = await userIdFrom(ctx);
|
||||||
|
const days = intParam(ctx, 'days', 30, 365);
|
||||||
|
|
||||||
|
const [answerStats, sessionStats, reportStats, wrongStats, favoriteStats, typeStats] = await Promise.all([
|
||||||
|
queryOne<{
|
||||||
|
totalAnswered: string;
|
||||||
|
correctCount: string;
|
||||||
|
wrongCount: string;
|
||||||
|
todayAnswered: string;
|
||||||
|
latestAnsweredAt: string | null;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
select count(*)::text as "totalAnswered",
|
||||||
|
count(*) filter (where is_correct is true)::text as "correctCount",
|
||||||
|
count(*) filter (where is_correct is false)::text as "wrongCount",
|
||||||
|
count(*) filter (where answered_at::date = current_date)::text as "todayAnswered",
|
||||||
|
max(answered_at) as "latestAnsweredAt"
|
||||||
|
from public.answer_records
|
||||||
|
where tenant_id = $1 and user_id = $2
|
||||||
|
`,
|
||||||
|
[tenantId, userId],
|
||||||
|
),
|
||||||
|
queryOne<{
|
||||||
|
totalSessions: string;
|
||||||
|
activeSessions: string;
|
||||||
|
finishedSessions: string;
|
||||||
|
latestStartedAt: string | null;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
select count(*)::text as "totalSessions",
|
||||||
|
count(*) filter (where finished_at is null and (expires_at is null or expires_at > now()))::text as "activeSessions",
|
||||||
|
count(*) filter (where finished_at is not null)::text as "finishedSessions",
|
||||||
|
max(started_at) as "latestStartedAt"
|
||||||
|
from public.practice_sessions
|
||||||
|
where tenant_id = $1 and user_id = $2
|
||||||
|
`,
|
||||||
|
[tenantId, userId],
|
||||||
|
),
|
||||||
|
queryOne<{
|
||||||
|
reportCount: string;
|
||||||
|
avgAccuracy: string | null;
|
||||||
|
bestScore: string | null;
|
||||||
|
latestSubmittedAt: string | null;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
select count(*)::text as "reportCount",
|
||||||
|
avg(accuracy)::text as "avgAccuracy",
|
||||||
|
max(score)::text as "bestScore",
|
||||||
|
max(submitted_at) as "latestSubmittedAt"
|
||||||
|
from public.practice_session_reports
|
||||||
|
where tenant_id = $1 and user_id = $2
|
||||||
|
`,
|
||||||
|
[tenantId, userId],
|
||||||
|
),
|
||||||
|
queryOne<{
|
||||||
|
unresolvedWrong: string;
|
||||||
|
resolvedWrong: string;
|
||||||
|
totalWrongBook: string;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
select count(*) filter (where resolved_at is null)::text as "unresolvedWrong",
|
||||||
|
count(*) filter (where resolved_at is not null)::text as "resolvedWrong",
|
||||||
|
count(*)::text as "totalWrongBook"
|
||||||
|
from public.wrong_questions
|
||||||
|
where tenant_id = $1 and user_id = $2
|
||||||
|
`,
|
||||||
|
[tenantId, userId],
|
||||||
|
),
|
||||||
|
queryOne<{ favoriteQuestions: string }>(
|
||||||
|
`
|
||||||
|
select count(*)::text as "favoriteQuestions"
|
||||||
|
from public.favorite_questions
|
||||||
|
where tenant_id = $1 and user_id = $2
|
||||||
|
`,
|
||||||
|
[tenantId, userId],
|
||||||
|
),
|
||||||
|
query<{
|
||||||
|
questionType: string;
|
||||||
|
typeLabel: string | null;
|
||||||
|
answeredCount: string;
|
||||||
|
correctCount: string;
|
||||||
|
wrongCount: string;
|
||||||
|
}>(
|
||||||
|
`
|
||||||
|
select q.type as "questionType", q.type_label as "typeLabel",
|
||||||
|
count(*)::text as "answeredCount",
|
||||||
|
count(*) filter (where ar.is_correct is true)::text as "correctCount",
|
||||||
|
count(*) filter (where ar.is_correct is false)::text as "wrongCount"
|
||||||
|
from public.answer_records ar
|
||||||
|
join public.questions q on q.id = ar.question_id and q.tenant_id = ar.tenant_id
|
||||||
|
where ar.tenant_id = $1
|
||||||
|
and ar.user_id = $2
|
||||||
|
and ar.answered_at >= current_date - ($3::integer * interval '1 day')
|
||||||
|
group by q.type, q.type_label
|
||||||
|
order by count(*) desc, q.type asc
|
||||||
|
limit 20
|
||||||
|
`,
|
||||||
|
[tenantId, userId, days],
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalAnswered = Number(answerStats?.totalAnswered || 0);
|
||||||
|
const correctCount = Number(answerStats?.correctCount || 0);
|
||||||
|
const wrongCount = Number(answerStats?.wrongCount || 0);
|
||||||
|
return {
|
||||||
|
item: {
|
||||||
|
windowDays: days,
|
||||||
|
answers: {
|
||||||
|
totalAnswered,
|
||||||
|
correctCount,
|
||||||
|
wrongCount,
|
||||||
|
todayAnswered: Number(answerStats?.todayAnswered || 0),
|
||||||
|
accuracy: round4(totalAnswered ? correctCount / totalAnswered : 0),
|
||||||
|
latestAnsweredAt: answerStats?.latestAnsweredAt || null,
|
||||||
|
},
|
||||||
|
sessions: {
|
||||||
|
totalSessions: Number(sessionStats?.totalSessions || 0),
|
||||||
|
activeSessions: Number(sessionStats?.activeSessions || 0),
|
||||||
|
finishedSessions: Number(sessionStats?.finishedSessions || 0),
|
||||||
|
latestStartedAt: sessionStats?.latestStartedAt || null,
|
||||||
|
},
|
||||||
|
reports: {
|
||||||
|
reportCount: Number(reportStats?.reportCount || 0),
|
||||||
|
avgAccuracy: round4(finiteNumber(reportStats?.avgAccuracy, 0)),
|
||||||
|
bestScore: round2(finiteNumber(reportStats?.bestScore, 0)),
|
||||||
|
latestSubmittedAt: reportStats?.latestSubmittedAt || null,
|
||||||
|
},
|
||||||
|
wrongBook: {
|
||||||
|
unresolvedWrong: Number(wrongStats?.unresolvedWrong || 0),
|
||||||
|
resolvedWrong: Number(wrongStats?.resolvedWrong || 0),
|
||||||
|
totalWrongBook: Number(wrongStats?.totalWrongBook || 0),
|
||||||
|
},
|
||||||
|
favorites: {
|
||||||
|
favoriteQuestions: Number(favoriteStats?.favoriteQuestions || 0),
|
||||||
|
},
|
||||||
|
questionTypes: typeStats.map(item => {
|
||||||
|
const answered = Number(item.answeredCount || 0);
|
||||||
|
const correct = Number(item.correctCount || 0);
|
||||||
|
return {
|
||||||
|
questionType: item.questionType,
|
||||||
|
typeLabel: item.typeLabel,
|
||||||
|
answeredCount: answered,
|
||||||
|
correctCount: correct,
|
||||||
|
wrongCount: Number(item.wrongCount || 0),
|
||||||
|
accuracy: round4(answered ? correct / answered : 0),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function learningTrendRoute(ctx: RequestContext) {
|
||||||
|
const tenantId = await tenantIdFrom(ctx);
|
||||||
|
const userId = await userIdFrom(ctx);
|
||||||
|
const days = intParam(ctx, 'days', 14, 365);
|
||||||
|
const from = dateParam(ctx, 'from');
|
||||||
|
const to = dateParam(ctx, 'to');
|
||||||
|
|
||||||
|
const rows = await query<TrendMetric>(
|
||||||
|
`
|
||||||
|
with bounds as (
|
||||||
|
select
|
||||||
|
coalesce($3::date, current_date - (($5::integer - 1) * interval '1 day'))::date as start_date,
|
||||||
|
coalesce($4::date, current_date)::date as end_date
|
||||||
|
),
|
||||||
|
dates as (
|
||||||
|
select generate_series(start_date, end_date, interval '1 day')::date as stat_date
|
||||||
|
from bounds
|
||||||
|
),
|
||||||
|
answers as (
|
||||||
|
select answered_at::date as stat_date,
|
||||||
|
count(*)::integer as answered_count,
|
||||||
|
count(*) filter (where is_correct is true)::integer as correct_count,
|
||||||
|
count(*) filter (where is_correct is false)::integer as wrong_count
|
||||||
|
from public.answer_records, bounds
|
||||||
|
where tenant_id = $1 and user_id = $2
|
||||||
|
and answered_at::date between bounds.start_date and bounds.end_date
|
||||||
|
group by answered_at::date
|
||||||
|
),
|
||||||
|
sessions as (
|
||||||
|
select started_at::date as stat_date, count(*)::integer as session_count
|
||||||
|
from public.practice_sessions, bounds
|
||||||
|
where tenant_id = $1 and user_id = $2
|
||||||
|
and started_at::date between bounds.start_date and bounds.end_date
|
||||||
|
group by started_at::date
|
||||||
|
),
|
||||||
|
reports as (
|
||||||
|
select submitted_at::date as stat_date,
|
||||||
|
count(*)::integer as report_count,
|
||||||
|
coalesce(sum(score), 0)::numeric as score,
|
||||||
|
coalesce(sum(total_score), 0)::numeric as total_score
|
||||||
|
from public.practice_session_reports, bounds
|
||||||
|
where tenant_id = $1 and user_id = $2
|
||||||
|
and submitted_at::date between bounds.start_date and bounds.end_date
|
||||||
|
group by submitted_at::date
|
||||||
|
)
|
||||||
|
select dates.stat_date::text as date,
|
||||||
|
coalesce(answers.answered_count, 0)::integer as "answeredCount",
|
||||||
|
coalesce(answers.correct_count, 0)::integer as "correctCount",
|
||||||
|
coalesce(answers.wrong_count, 0)::integer as "wrongCount",
|
||||||
|
case when coalesce(answers.answered_count, 0) = 0 then 0
|
||||||
|
else round((answers.correct_count::numeric / answers.answered_count::numeric), 4)
|
||||||
|
end as accuracy,
|
||||||
|
coalesce(sessions.session_count, 0)::integer as "sessionCount",
|
||||||
|
coalesce(reports.report_count, 0)::integer as "reportCount",
|
||||||
|
coalesce(reports.score, 0)::numeric as score,
|
||||||
|
coalesce(reports.total_score, 0)::numeric as "totalScore"
|
||||||
|
from dates
|
||||||
|
left join answers on answers.stat_date = dates.stat_date
|
||||||
|
left join sessions on sessions.stat_date = dates.stat_date
|
||||||
|
left join reports on reports.stat_date = dates.stat_date
|
||||||
|
order by dates.stat_date asc
|
||||||
|
`,
|
||||||
|
[tenantId, userId, from || null, to || null, days],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: rows.map(row => ({
|
||||||
|
...row,
|
||||||
|
answeredCount: Number(row.answeredCount || 0),
|
||||||
|
correctCount: Number(row.correctCount || 0),
|
||||||
|
wrongCount: Number(row.wrongCount || 0),
|
||||||
|
accuracy: finiteNumber(row.accuracy, 0),
|
||||||
|
sessionCount: Number(row.sessionCount || 0),
|
||||||
|
reportCount: Number(row.reportCount || 0),
|
||||||
|
score: finiteNumber(row.score, 0),
|
||||||
|
totalScore: finiteNumber(row.totalScore, 0),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wrongQuestionReviewPlanRoute(ctx: RequestContext) {
|
||||||
|
const tenantId = await tenantIdFrom(ctx);
|
||||||
|
const userId = await userIdFrom(ctx);
|
||||||
|
const limit = intParam(ctx, 'limit', 20, 100);
|
||||||
|
const includeResolved = stringParam(ctx, 'includeResolved') === 'true';
|
||||||
|
|
||||||
|
const items = await query(
|
||||||
|
`
|
||||||
|
select wq.question_id as "questionId", wq.wrong_count as "wrongCount",
|
||||||
|
wq.last_wrong_at as "lastWrongAt", wq.resolved_at as "resolvedAt",
|
||||||
|
q.type, q.type_label as "typeLabel", q.subject_id as "subjectId",
|
||||||
|
s.name as "subjectName", q.category_id as "categoryId",
|
||||||
|
c.name as "categoryName", q.content_node_id as "contentNodeId",
|
||||||
|
cn.name as "contentNodeName", v.content,
|
||||||
|
coalesce(ar.answer_attempts, 0)::integer as "answerAttempts",
|
||||||
|
ar.last_answered_at as "lastAnsweredAt",
|
||||||
|
case
|
||||||
|
when wq.resolved_at is not null then 'resolved'
|
||||||
|
when wq.wrong_count >= 3 then 'high_priority'
|
||||||
|
when wq.last_wrong_at <= now() - interval '3 days' then 'due'
|
||||||
|
else 'new'
|
||||||
|
end as "reviewStatus",
|
||||||
|
(
|
||||||
|
wq.last_wrong_at
|
||||||
|
+ case
|
||||||
|
when wq.wrong_count >= 3 then interval '1 day'
|
||||||
|
when wq.wrong_count = 2 then interval '2 days'
|
||||||
|
else interval '3 days'
|
||||||
|
end
|
||||||
|
) as "suggestedReviewAt"
|
||||||
|
from public.wrong_questions wq
|
||||||
|
join public.questions q on q.id = wq.question_id and q.tenant_id = wq.tenant_id
|
||||||
|
left join public.question_versions v on v.id = q.current_version_id
|
||||||
|
left join public.subjects s on s.id = q.subject_id and s.tenant_id = q.tenant_id
|
||||||
|
left join public.categories c on c.id = q.category_id and c.tenant_id = q.tenant_id
|
||||||
|
left join public.content_nodes cn on cn.id = q.content_node_id and cn.tenant_id = q.tenant_id
|
||||||
|
left join lateral (
|
||||||
|
select count(*)::integer as answer_attempts, max(answered_at) as last_answered_at
|
||||||
|
from public.answer_records
|
||||||
|
where tenant_id = wq.tenant_id
|
||||||
|
and user_id = wq.user_id
|
||||||
|
and question_id = wq.question_id
|
||||||
|
) ar on true
|
||||||
|
where wq.tenant_id = $1
|
||||||
|
and wq.user_id = $2
|
||||||
|
and ($3::boolean = true or wq.resolved_at is null)
|
||||||
|
and q.status = 'published'
|
||||||
|
order by
|
||||||
|
case
|
||||||
|
when wq.resolved_at is not null then 4
|
||||||
|
when wq.wrong_count >= 3 then 0
|
||||||
|
when wq.last_wrong_at <= now() - interval '3 days' then 1
|
||||||
|
else 2
|
||||||
|
end,
|
||||||
|
wq.wrong_count desc,
|
||||||
|
wq.last_wrong_at asc
|
||||||
|
limit $4
|
||||||
|
`,
|
||||||
|
[tenantId, userId, includeResolved, limit],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
nextAction: {
|
||||||
|
mode: 'wrong_review',
|
||||||
|
endpoint: 'POST /api/learning/practice-sessions',
|
||||||
|
body: { mode: 'wrong_review', questionLimit: Math.min(limit, 50) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function favoriteQuestionsRoute(ctx: RequestContext) {
|
export async function favoriteQuestionsRoute(ctx: RequestContext) {
|
||||||
const tenantId = await tenantIdFrom(ctx);
|
const tenantId = await tenantIdFrom(ctx);
|
||||||
const userId = await userIdFrom(ctx);
|
const userId = await userIdFrom(ctx);
|
||||||
|
|||||||
@@ -58,6 +58,8 @@
|
|||||||
| 收藏夹 | 可联调 | `/api/learning/favorites/questions` |
|
| 收藏夹 | 可联调 | `/api/learning/favorites/questions` |
|
||||||
| 免费用户题量限制 | 可联调 | `practice_daily_usage` + `practice_access_events`;支持内容 accessRules、每日额度、session 截断、SVIP-only 拦截 |
|
| 免费用户题量限制 | 可联调 | `practice_daily_usage` + `practice_access_events`;支持内容 accessRules、每日额度、session 截断、SVIP-only 拦截 |
|
||||||
| 模考交卷报告 | 可联调 | `POST /api/learning/practice-sessions/submit`、`GET /api/learning/practice-sessions/report`、`GET /api/learning/practice-reports`;后端按 session 快照评分、分段统计、错题解析汇总,重复提交幂等 |
|
| 模考交卷报告 | 可联调 | `POST /api/learning/practice-sessions/submit`、`GET /api/learning/practice-sessions/report`、`GET /api/learning/practice-reports`;后端按 session 快照评分、分段统计、错题解析汇总,重复提交幂等 |
|
||||||
|
| 学习历史/统计/趋势 | 可联调 | `GET /api/learning/practice-sessions/history`、`GET /api/learning/stats`、`GET /api/learning/trend`;可支撑个人中心、练习历史、正确率趋势和题型分布 |
|
||||||
|
| 错题复习计划 | 可联调 | `GET /api/learning/wrong-questions/review-plan` + `POST /api/learning/practice-sessions` 的 `mode=wrong_review`,后端从本人错题本安全组卷 |
|
||||||
|
|
||||||
## 背单词、知识手册、分数线、视频
|
## 背单词、知识手册、分数线、视频
|
||||||
|
|
||||||
|
|||||||
@@ -20,17 +20,17 @@
|
|||||||
| 首页/学生看板 | `pages/StudentDashboardNew.tsx` | 部分覆盖 | 品牌、Banner、公告、入口、个人统计有基础;缺完整运营动态/学习任务聚合 |
|
| 首页/学生看板 | `pages/StudentDashboardNew.tsx` | 部分覆盖 | 品牌、Banner、公告、入口、个人统计有基础;缺完整运营动态/学习任务聚合 |
|
||||||
| 题库入口 | `pages/SubjectSelector.tsx`、`RegionArchitectureEditor.tsx` | 已覆盖 | 前端应改接 `content_entries/content_nodes` |
|
| 题库入口 | `pages/SubjectSelector.tsx`、`RegionArchitectureEditor.tsx` | 已覆盖 | 前端应改接 `content_entries/content_nodes` |
|
||||||
| 多级分类树 | 旧 module/subject/category 树 | 已覆盖 | 新后端支持任意深度和 `marker_type`;前端不要写死层级 |
|
| 多级分类树 | 旧 module/subject/category 树 | 已覆盖 | 新后端支持任意深度和 `marker_type`;前端不要写死层级 |
|
||||||
| 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 免费额度/SVIP 校验已由后端强制;继续补断点续练、更多题型渲染 |
|
| 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 免费额度/SVIP 校验、session 快照、练习历史和趋势统计已由后端强制;继续补断点续练、更多题型渲染 |
|
||||||
| 随机刷题 | `pages/Quiz.tsx` | 已覆盖 | 已有 blueprint/session 快照和访问控制,前端需按 mode 调用 |
|
| 随机刷题 | `pages/Quiz.tsx` | 已覆盖 | 已有 blueprint/session 快照、访问控制和历史统计,前端需按 mode 调用 |
|
||||||
| 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | blueprint、session 快照、交卷评分、分段统计和错题解析汇总已覆盖;后续补排名、断点续练、复盘体验 |
|
| 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | blueprint、session 快照、交卷评分、分段统计和错题解析汇总已覆盖;后续补排名、断点续练、复盘体验 |
|
||||||
| 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 后续补错题复习计划 |
|
| 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 错题列表、移出错题、复习计划和 `wrong_review` 后端组卷已覆盖;后续补更细的间隔复习算法 |
|
||||||
| 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 |
|
| 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 |
|
||||||
| 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志已有;缺深度防盗链、动态水印、播放统计报表 |
|
| 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志已有;缺深度防盗链、动态水印、播放统计报表 |
|
||||||
| 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表/进度/收藏/统计已有;缺完整艾宾浩斯算法、每日计划、收藏练习细节 |
|
| 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表/进度/收藏/统计已有;缺完整艾宾浩斯算法、每日计划、收藏练习细节 |
|
||||||
| 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 |
|
| 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 |
|
||||||
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 |
|
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 |
|
||||||
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐/订单/权益/激活码已有;缺真实支付、优惠券抵扣 |
|
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐/订单/权益/激活码已有;缺真实支付、优惠券抵扣 |
|
||||||
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计有;缺完整勋章、签到、学习报告 |
|
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计和趋势已有;缺完整勋章、签到、学习报告可视化 |
|
||||||
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账/签名下载已有;缺 PDF 预览、下载水印、防盗链 |
|
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账/签名下载已有;缺 PDF 预览、下载水印、防盗链 |
|
||||||
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |
|
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |
|
||||||
|
|
||||||
|
|||||||
@@ -8,13 +8,13 @@
|
|||||||
|
|
||||||
- Supabase/PostgreSQL 多租户 schema、RLS、索引、触发器。
|
- Supabase/PostgreSQL 多租户 schema、RLS、索引、触发器。
|
||||||
- Node.js API 分层:`core/features`。
|
- Node.js API 分层:`core/features`。
|
||||||
- 学生端核心 API:题库、练习、答题、模考交卷报告、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单、权益、个人中心。
|
- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单、权益、个人中心。
|
||||||
- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、激活码、优惠券、成员权限、审计、内容管理。
|
- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、激活码、优惠券、成员权限、审计、内容管理。
|
||||||
- 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。
|
- 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。
|
||||||
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。
|
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。
|
||||||
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
|
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
|
||||||
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
|
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
|
||||||
- 模考报告:`practice_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询。
|
- 模考报告与学习统计:`practice_session_reports/practice_session_report_sections` 支持交卷、评分、题型/小节统计、错题解析汇总和历史查询;`/api/learning/stats`、`trend`、`practice-sessions/history`、`wrong-questions/review-plan` 可支撑个人中心和学习报告基础页。
|
||||||
- 练习访问控制:`practice_daily_usage/practice_access_events` 支持免费每日额度、SVIP 范围校验、SVIP-only 内容拦截和答题 session 快照保护。
|
- 练习访问控制:`practice_daily_usage/practice_access_events` 支持免费每日额度、SVIP 范围校验、SVIP-only 内容拦截和答题 session 快照保护。
|
||||||
- 内容导入:题目、单词、知识手册 JSON 预览、校验、导入、幂等、审计。
|
- 内容导入:题目、单词、知识手册 JSON 预览、校验、导入、幂等、审计。
|
||||||
- 本地验证:`npm run check:refactor` 已通过。
|
- 本地验证:`npm run check:refactor` 已通过。
|
||||||
@@ -78,10 +78,9 @@
|
|||||||
- 单题视频和通用知识视频混合推荐。
|
- 单题视频和通用知识视频混合推荐。
|
||||||
|
|
||||||
6. 学习统计
|
6. 学习统计
|
||||||
- 已完成免费额度、练习访问事件、模考交卷评分报告基础。
|
- 已完成免费额度、练习访问事件、模考交卷评分报告、练习历史、正确率趋势、题型分布、错题复习计划。
|
||||||
- 继续补练习历史、正确率趋势、题型分布、错题复习计划。
|
- 继续补单词复习算法、每日计划、排行榜。
|
||||||
- 单词复习算法、每日计划、排行榜。
|
- 继续补模考排名、断点续练、复盘体验。
|
||||||
- 模考排名、断点续练、复盘体验。
|
|
||||||
|
|
||||||
7. 数据看板
|
7. 数据看板
|
||||||
- 收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量。
|
- 收益、注册趋势、答题次数、收入趋势、题型分布、科目数量、题目总量。
|
||||||
|
|||||||
@@ -139,7 +139,9 @@ tenant:<tenantId>:theme
|
|||||||
| 提交答案 | `POST /api/learning/answers` |
|
| 提交答案 | `POST /api/learning/answers` |
|
||||||
| 交卷/报告 | `POST /api/learning/practice-sessions/submit`、`GET /api/learning/practice-sessions/report`、`GET /api/learning/practice-reports` |
|
| 交卷/报告 | `POST /api/learning/practice-sessions/submit`、`GET /api/learning/practice-sessions/report`、`GET /api/learning/practice-reports` |
|
||||||
| 错题本 | `GET /api/learning/wrong-questions`、`POST /api/learning/wrong-questions/resolve` |
|
| 错题本 | `GET /api/learning/wrong-questions`、`POST /api/learning/wrong-questions/resolve` |
|
||||||
|
| 错题复习 | `GET /api/learning/wrong-questions/review-plan`、`POST /api/learning/practice-sessions` with `mode=wrong_review` |
|
||||||
| 收藏夹 | `GET/POST /api/learning/favorites/questions` |
|
| 收藏夹 | `GET/POST /api/learning/favorites/questions` |
|
||||||
|
| 练习历史/统计 | `GET /api/learning/practice-sessions/history`、`GET /api/learning/stats`、`GET /api/learning/trend` |
|
||||||
| 题目视频 | `GET /api/questions/{questionId}/videos`、`POST /api/questions/videos/batch`、`POST /api/videos/play` |
|
| 题目视频 | `GET /api/questions/{questionId}/videos`、`POST /api/questions/videos/batch`、`POST /api/videos/play` |
|
||||||
| 背单词 | `/api/catalog/vocabulary-units`、`/api/catalog/vocabulary-words` |
|
| 背单词 | `/api/catalog/vocabulary-units`、`/api/catalog/vocabulary-words` |
|
||||||
| 单词进度 | `/api/learning/vocabulary/progress`、`/api/learning/vocabulary/stats` |
|
| 单词进度 | `/api/learning/vocabulary/progress`、`/api/learning/vocabulary/stats` |
|
||||||
@@ -262,6 +264,35 @@ tenant:<tenantId>:theme
|
|||||||
- `score` 是逐题得分合计;`totalScore` 保留后台配置的卷面总分。测试或预发数据题量不足时,两者不一定按百分制等比换算,前端展示时不要自行重算。
|
- `score` 是逐题得分合计;`totalScore` 保留后台配置的卷面总分。测试或预发数据题量不足时,两者不一定按百分制等比换算,前端展示时不要自行重算。
|
||||||
- 错题复盘优先使用 `wrongQuestionIds` 和 `questionResults`,题目详情仍可按现有题目接口或 session 快照加载。
|
- 错题复盘优先使用 `wrongQuestionIds` 和 `questionResults`,题目详情仍可按现有题目接口或 session 快照加载。
|
||||||
|
|
||||||
|
### 练习历史、统计和错题复习
|
||||||
|
|
||||||
|
个人中心和学习报告页优先使用后端聚合接口,不要让前端遍历全部答题记录自行统计。
|
||||||
|
|
||||||
|
接口用途:
|
||||||
|
|
||||||
|
| 页面/组件 | 接口 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 练习历史列表 | `GET /api/learning/practice-sessions/history?limit=20` | 返回 session、报告、已答数量、正确数、状态 |
|
||||||
|
| 学习概览卡片 | `GET /api/learning/stats?days=30` | 返回总答题、正确率、报告数、错题数、收藏数、题型分布 |
|
||||||
|
| 正确率趋势图 | `GET /api/learning/trend?days=14` | 返回每日答题数、正确数、错题数、session 数、报告数 |
|
||||||
|
| 错题复习入口 | `GET /api/learning/wrong-questions/review-plan?limit=20` | 返回建议复习题和后端组卷 nextAction |
|
||||||
|
|
||||||
|
错题复习创建 session:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "wrong_review",
|
||||||
|
"questionLimit": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端处理规则:
|
||||||
|
|
||||||
|
- 不要把错题 ID 列表从前端传回后端组卷;`wrong_review` 会由后端按当前用户错题本安全组卷。
|
||||||
|
- `review-plan.nextAction` 可直接用于按钮配置,但仍需使用当前登录 session 调用。
|
||||||
|
- 收藏夹复习同理可调用 `POST /api/learning/practice-sessions`,body 为 `{ "mode": "favorite_review", "questionLimit": 20 }`。
|
||||||
|
- 趋势图以接口返回日期桶为准,缺失日期后端会补 0,不需要前端补点。
|
||||||
|
|
||||||
## 视频播放契约
|
## 视频播放契约
|
||||||
|
|
||||||
题目视频分为 `free`、`svip`、`video_quota` 三种访问模式。列表接口只用于展示标题、封面、时长、访问模式和试看秒数;除免费公开视频外,列表和搜索接口不会返回可播放 URL。
|
题目视频分为 `free`、`svip`、`video_quota` 三种访问模式。列表接口只用于展示标题、封面、时长、访问模式和试看秒数;除免费公开视频外,列表和搜索接口不会返回可播放 URL。
|
||||||
|
|||||||
@@ -746,6 +746,69 @@ async function testCatalogAndLearning() {
|
|||||||
query: { status: 'all' },
|
query: { status: 'all' },
|
||||||
});
|
});
|
||||||
assert.ok(wrong.items?.some(item => item.questionId === ids.question), 'wrong book should include smoke question');
|
assert.ok(wrong.items?.some(item => item.questionId === ids.question), 'wrong book should include smoke question');
|
||||||
|
|
||||||
|
const wrongReviewPlan = await request('/api/learning/wrong-questions/review-plan', {
|
||||||
|
userId: false,
|
||||||
|
headers: { authorization: `Bearer ${freeLogin.session.token}` },
|
||||||
|
query: { limit: 10 },
|
||||||
|
});
|
||||||
|
assert.ok(wrongReviewPlan.items?.some(item => item.questionId === ids.question), 'wrong review plan should include unresolved wrong question');
|
||||||
|
assert.equal(wrongReviewPlan.nextAction?.body?.mode, 'wrong_review', 'wrong review plan should guide backend session creation');
|
||||||
|
|
||||||
|
const wrongReviewSession = await request('/api/learning/practice-sessions', {
|
||||||
|
userId: false,
|
||||||
|
headers: { authorization: `Bearer ${freeLogin.session.token}` },
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
mode: 'wrong_review',
|
||||||
|
questionLimit: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(wrongReviewSession.item?.mode, 'wrong_review', 'wrong review should create a dedicated practice session');
|
||||||
|
assert.ok(wrongReviewSession.item?.questionIds?.includes(ids.question), 'wrong review session should be assembled by backend wrong book');
|
||||||
|
|
||||||
|
const history = await request('/api/learning/practice-sessions/history', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
query: { mode: 'mock_exam', limit: 20 },
|
||||||
|
});
|
||||||
|
assert.ok(history.items?.some(item => item.id === mockSession.item.id && item.reportId === mockReport.item.id), 'practice history should include submitted mock session');
|
||||||
|
|
||||||
|
const learningStats = await request('/api/learning/stats', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
query: { days: 30 },
|
||||||
|
});
|
||||||
|
assert.ok(learningStats.item?.answers?.totalAnswered >= 2, 'learning stats should count answer records');
|
||||||
|
assert.ok(learningStats.item?.reports?.reportCount >= 1, 'learning stats should count reports');
|
||||||
|
assert.ok(learningStats.item?.questionTypes?.some(item => item.questionType === 'choice'), 'learning stats should include question type distribution');
|
||||||
|
|
||||||
|
const learningTrend = await request('/api/learning/trend', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
query: { days: 7 },
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(learningTrend.items), 'learning trend should return daily buckets');
|
||||||
|
assert.ok(learningTrend.items.length >= 1, 'learning trend should include at least one bucket');
|
||||||
|
assert.ok(learningTrend.items.some(item => item.answeredCount >= 2), 'learning trend should include answer activity');
|
||||||
|
|
||||||
|
await request('/api/learning/favorites/questions', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
questionId: ids.questionThree,
|
||||||
|
favorite: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const favoriteReviewSession = await request('/api/learning/practice-sessions', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
mode: 'favorite_review',
|
||||||
|
questionLimit: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(favoriteReviewSession.item?.mode, 'favorite_review', 'favorite review should create a dedicated practice session');
|
||||||
|
assert.ok(favoriteReviewSession.item?.questionIds?.includes(ids.questionThree), 'favorite review session should be assembled by backend favorites');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function testProfile() {
|
async function testProfile() {
|
||||||
|
|||||||
18
supabase/migrations/202606210013_learning_stats_history.sql
Normal file
18
supabase/migrations/202606210013_learning_stats_history.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
create index if not exists idx_practice_sessions_user_started
|
||||||
|
on public.practice_sessions(tenant_id, user_id, started_at desc);
|
||||||
|
|
||||||
|
create index if not exists idx_practice_sessions_user_finished
|
||||||
|
on public.practice_sessions(tenant_id, user_id, finished_at desc)
|
||||||
|
where finished_at is not null;
|
||||||
|
|
||||||
|
create index if not exists idx_answer_records_user_answered
|
||||||
|
on public.answer_records(tenant_id, user_id, answered_at desc);
|
||||||
|
|
||||||
|
create index if not exists idx_answer_records_session_latest
|
||||||
|
on public.answer_records(tenant_id, user_id, practice_session_id, question_id, answered_at desc);
|
||||||
|
|
||||||
|
create index if not exists idx_wrong_questions_user_review
|
||||||
|
on public.wrong_questions(tenant_id, user_id, resolved_at, last_wrong_at desc, wrong_count desc);
|
||||||
|
|
||||||
|
create index if not exists idx_favorite_questions_user_created
|
||||||
|
on public.favorite_questions(tenant_id, user_id, created_at desc);
|
||||||
Reference in New Issue
Block a user