diff --git a/apps/api/src/features/catalog/navigation.ts b/apps/api/src/features/catalog/navigation.ts index 72b9bf3b..0200acbe 100644 --- a/apps/api/src/features/catalog/navigation.ts +++ b/apps/api/src/features/catalog/navigation.ts @@ -84,7 +84,7 @@ export async function contentNodesRoute(ctx: RequestContext) { marker_type as "markerType", marker_config as "markerConfig", path::text as path, depth, sort_order as "order", is_active as "isActive", is_selectable as "isSelectable", - is_leaf as "isLeaf", metadata, + is_leaf as "isLeaf", access_rules as "accessRules", metadata, created_at as "createdAt", updated_at as "updatedAt" from public.content_nodes where ${filters.join(' and ')} @@ -132,7 +132,7 @@ export async function questionCollectionsRoute(ctx: RequestContext) { legacy_id as "legacyId", name, collection_type as "collectionType", source_type as "sourceType", filters, question_count as "questionCount", total_score as "totalScore", duration_minutes as "durationMinutes", - status, sort_order as "order", metadata, + status, sort_order as "order", access_rules as "accessRules", metadata, created_at as "createdAt", updated_at as "updatedAt" from public.question_collections where ${filters.join(' and ')} @@ -180,7 +180,7 @@ export async function practiceBlueprintsRoute(ctx: RequestContext) { legacy_id as "legacyId", name, mode, assembly_type as "assemblyType", question_limit as "questionLimit", duration_minutes as "durationMinutes", total_score as "totalScore", - pass_score as "passScore", sections, rules, status, + pass_score as "passScore", sections, rules, access_rules as "accessRules", status, sort_order as "order", created_at as "createdAt", updated_at as "updatedAt" from public.practice_blueprints diff --git a/apps/api/src/features/learning/access.ts b/apps/api/src/features/learning/access.ts new file mode 100644 index 00000000..a0390878 --- /dev/null +++ b/apps/api/src/features/learning/access.ts @@ -0,0 +1,564 @@ +import type pg from 'pg'; +import { HttpError } from '../../core/http.js'; + +type JsonObject = Record; + +export interface PracticeAssemblyAccessInput { + mode: string; + blueprintId: string | null; + collectionId: string | null; + entryId: string | null; + contentNodeId: string | null; + rules: JsonObject; +} + +export interface PracticeAccessDecision { + accessMode: 'free' | 'svip' | 'staff'; + entitlementId: string | null; + questionIds: string[]; + consumedFreeQuota: number; + accessSnapshot: JsonObject; +} + +interface PracticeAccessScopeRow { + entryId: string | null; + entryRegionId: string | null; + entryVisibility: string | null; + entryAccessRules: JsonObject | null; + nodeId: string | null; + nodeRegionId: string | null; + nodeAccessRules: JsonObject | null; + nodeMetadata: JsonObject | null; + collectionId: string | null; + collectionRegionId: string | null; + collectionSubjectId: string | null; + collectionQuestionBankId: string | null; + collectionAccessRules: JsonObject | null; + collectionMetadata: JsonObject | null; + blueprintId: string | null; + blueprintRegionId: string | null; + blueprintAccessRules: JsonObject | null; + blueprintRules: JsonObject | null; +} + +interface QuestionScopeRow { + id: string; + regionId: string | null; + subjectId: string | null; + questionBankId: string | null; + contentNodeId: string | null; + primaryCollectionId: string | null; +} + +interface EntitlementRow { + id: string; + scopeType: string; + scopeId: string | null; + expiresAt: string | null; +} + +interface StaffRow { + id: string; + role: string; +} + +function isObject(value: unknown): value is JsonObject { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function objectValue(value: unknown): JsonObject { + return isObject(value) ? value : {}; +} + +function positiveIntValue(value: unknown, fallback: number, max = 1000) { + const parsed = Number(value ?? fallback); + if (!Number.isFinite(parsed) || parsed < 0) return fallback; + return Math.min(Math.trunc(parsed), max); +} + +function stringValue(value: unknown) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +function boolValue(value: unknown, fallback = false) { + return typeof value === 'boolean' ? value : fallback; +} + +function mergeRules(...rules: unknown[]): JsonObject { + return rules.reduce((acc, rule) => ({ ...acc, ...objectValue(rule) }), {}); +} + +function resolveAccessMode(scope: PracticeAccessScopeRow, assembly: PracticeAssemblyAccessInput, mergedRules: JsonObject) { + const explicitMode = stringValue(mergedRules.accessMode || mergedRules.mode || mergedRules.requiredAccess); + if (['public', 'free'].includes(explicitMode)) return 'free'; + if (['members', 'svip', 'paid'].includes(explicitMode)) return 'svip'; + if (scope.entryVisibility === 'svip') return 'svip'; + if (scope.entryVisibility === 'members') return 'free'; + if (assembly.mode === 'mock_exam' && boolValue(mergedRules.mockExamRequiresSvip, false)) return 'svip'; + return 'free'; +} + +function scopeCandidates(input: { + tenantId: string; + mergedRules: JsonObject; + scope: PracticeAccessScopeRow; + questions: QuestionScopeRow[]; +}) { + const regionIds = new Set(); + const subjectIds = new Set(); + const questionBankIds = new Set(); + + for (const id of [input.scope.blueprintRegionId, input.scope.collectionRegionId, input.scope.nodeRegionId, input.scope.entryRegionId]) { + if (id) regionIds.add(id); + } + for (const question of input.questions) { + if (question.regionId) regionIds.add(question.regionId); + if (question.subjectId) subjectIds.add(question.subjectId); + if (question.questionBankId) questionBankIds.add(question.questionBankId); + } + + const requiredRegionId = stringValue(input.mergedRules.regionId || input.mergedRules.requiredRegionId); + const requiredSubjectId = stringValue(input.mergedRules.subjectId || input.mergedRules.requiredSubjectId); + const requiredQuestionBankId = stringValue(input.mergedRules.questionBankId || input.mergedRules.requiredQuestionBankId); + if (requiredRegionId) regionIds.add(requiredRegionId); + if (requiredSubjectId) subjectIds.add(requiredSubjectId); + if (requiredQuestionBankId) questionBankIds.add(requiredQuestionBankId); + if (input.scope.collectionSubjectId) subjectIds.add(input.scope.collectionSubjectId); + if (input.scope.collectionQuestionBankId) questionBankIds.add(input.scope.collectionQuestionBankId); + + return { + tenantIds: [input.tenantId], + regionIds: [...regionIds], + subjectIds: [...subjectIds], + questionBankIds: [...questionBankIds], + }; +} + +function hasScopeAccess(entitlement: EntitlementRow, candidates: ReturnType) { + if (entitlement.scopeType === 'tenant') return true; + if (!entitlement.scopeId) return false; + if (entitlement.scopeType === 'region') return candidates.regionIds.includes(entitlement.scopeId); + if (entitlement.scopeType === 'subject') return candidates.subjectIds.includes(entitlement.scopeId); + if (entitlement.scopeType === 'question_bank') return candidates.questionBankIds.includes(entitlement.scopeId); + return false; +} + +async function loadScope(client: pg.PoolClient, tenantId: string, assembly: PracticeAssemblyAccessInput) { + const result = await client.query( + ` + select + ce.id as "entryId", + ce.region_id as "entryRegionId", + ce.visibility as "entryVisibility", + ce.access_rules as "entryAccessRules", + cn.id as "nodeId", + cn.region_id as "nodeRegionId", + cn.access_rules as "nodeAccessRules", + cn.metadata as "nodeMetadata", + qc.id as "collectionId", + qc.region_id as "collectionRegionId", + qc.subject_id as "collectionSubjectId", + qc.question_bank_id as "collectionQuestionBankId", + qc.access_rules as "collectionAccessRules", + qc.metadata as "collectionMetadata", + pb.id as "blueprintId", + pb.region_id as "blueprintRegionId", + pb.access_rules as "blueprintAccessRules", + pb.rules as "blueprintRules" + from (select $1::uuid as tenant_id) t + left join public.content_entries ce on ce.tenant_id = t.tenant_id and ce.id = $2::uuid + left join public.content_nodes cn on cn.tenant_id = t.tenant_id and cn.id = $3::uuid + left join public.question_collections qc on qc.tenant_id = t.tenant_id and qc.id = $4::uuid + left join public.practice_blueprints pb on pb.tenant_id = t.tenant_id and pb.id = $5::uuid + limit 1 + `, + [tenantId, assembly.entryId, assembly.contentNodeId, assembly.collectionId, assembly.blueprintId], + ); + return result.rows[0] || { + entryId: null, + entryRegionId: null, + entryVisibility: null, + entryAccessRules: null, + nodeId: null, + nodeRegionId: null, + nodeAccessRules: null, + nodeMetadata: null, + collectionId: null, + collectionRegionId: null, + collectionSubjectId: null, + collectionQuestionBankId: null, + collectionAccessRules: null, + collectionMetadata: null, + blueprintId: null, + blueprintRegionId: null, + blueprintAccessRules: null, + blueprintRules: null, + }; +} + +async function loadQuestionScopes(client: pg.PoolClient, tenantId: string, questionIds: string[]) { + if (!questionIds.length) return []; + const result = await client.query( + ` + select q.id, + coalesce(qb.region_id, s.region_id) as "regionId", + q.subject_id as "subjectId", + q.question_bank_id as "questionBankId", + q.content_node_id as "contentNodeId", + q.primary_collection_id as "primaryCollectionId" + from public.questions q + left join public.subjects s on s.tenant_id = q.tenant_id and s.id = q.subject_id + left join public.question_banks qb on qb.tenant_id = q.tenant_id and qb.id = q.question_bank_id + where q.tenant_id = $1 and q.id = any($2::uuid[]) + `, + [tenantId, questionIds], + ); + const order = new Map(questionIds.map((id, index) => [id, index])); + return result.rows.sort((left, right) => (order.get(left.id) ?? 0) - (order.get(right.id) ?? 0)); +} + +async function loadActiveEntitlements(client: pg.PoolClient, tenantId: string, userId: string) { + const result = await client.query( + ` + select id, scope_type as "scopeType", scope_id as "scopeId", expires_at as "expiresAt" + from public.entitlements + where tenant_id = $1 + and user_id = $2 + and entitlement_type = 'svip' + and status = 'active' + and starts_at <= now() + and (expires_at is null or expires_at > now()) + order by case scope_type + when 'subject' then 0 + when 'question_bank' then 1 + when 'region' then 2 + else 3 + end, expires_at desc nulls first + `, + [tenantId, userId], + ); + return result.rows; +} + +async function loadStaffMembership(client: pg.PoolClient, tenantId: string, userId: string) { + const result = await client.query( + ` + select id, role + from public.tenant_memberships + where tenant_id = $1 + and user_id = $2 + and status = 'active' + and role in ('tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher') + order by case role + when 'tenant_owner' then 0 + when 'tenant_admin' then 1 + when 'tenant_operator' then 2 + else 3 + end + limit 1 + `, + [tenantId, userId], + ); + return result.rows[0] || null; +} + +async function consumeDailyFreeQuota(client: pg.PoolClient, input: { + tenantId: string; + userId: string; + requestedCount: number; + dailyLimit: number; + scopeType: string; + scopeId: string | null; + metadata: JsonObject; +}) { + if (input.dailyLimit <= 0) return { grantedCount: 0, consumedFreeQuota: 0, remainingBefore: 0, remainingAfter: 0 }; + + const usage = await client.query<{ used_count: number }>( + ` + insert into public.practice_daily_usage ( + tenant_id, user_id, usage_date, scope_type, scope_id, free_limit, used_count, metadata + ) + values ($1, $2, current_date, $3, $4::uuid, $5, 0, $6::jsonb) + on conflict (tenant_id, user_id, usage_date, scope_type, scope_id) + do update set free_limit = excluded.free_limit, + metadata = public.practice_daily_usage.metadata || excluded.metadata, + updated_at = now() + returning used_count + `, + [ + input.tenantId, + input.userId, + input.scopeType, + input.scopeId, + input.dailyLimit, + JSON.stringify(input.metadata), + ], + ); + + const usedCount = Number(usage.rows[0]?.used_count || 0); + const remainingBefore = Math.max(0, input.dailyLimit - usedCount); + const grantedCount = Math.min(input.requestedCount, remainingBefore); + if (grantedCount > 0) { + await client.query( + ` + update public.practice_daily_usage + set used_count = used_count + $5, + updated_at = now() + where tenant_id = $1 + and user_id = $2 + and usage_date = current_date + and scope_type = $3 + and (($4::uuid is null and scope_id is null) or scope_id = $4::uuid) + and used_count + $5 <= free_limit + `, + [input.tenantId, input.userId, input.scopeType, input.scopeId, grantedCount], + ); + } + + return { + grantedCount, + consumedFreeQuota: grantedCount, + remainingBefore, + remainingAfter: Math.max(0, remainingBefore - grantedCount), + }; +} + +export async function authorizePracticeSession(client: pg.PoolClient, input: { + tenantId: string; + userId: string; + questionIds: string[]; + assembly: PracticeAssemblyAccessInput; +}): Promise { + const scope = await loadScope(client, input.tenantId, input.assembly); + const questionScopes = await loadQuestionScopes(client, input.tenantId, input.questionIds); + const mergedRules = mergeRules( + scope.entryAccessRules, + scope.nodeMetadata?.accessRules, + scope.nodeAccessRules, + scope.collectionMetadata?.accessRules, + scope.collectionAccessRules, + scope.blueprintRules?.accessRules, + scope.blueprintAccessRules, + input.assembly.rules?.accessRules, + ); + const requiredAccess = resolveAccessMode(scope, input.assembly, mergedRules); + const dailyLimit = positiveIntValue( + mergedRules.freeDailyLimit ?? mergedRules.dailyFreeLimit ?? mergedRules.freeQuestionLimit, + 25, + 500, + ); + const maxFreePerSession = positiveIntValue( + mergedRules.freeSessionLimit ?? mergedRules.maxFreePerSession, + dailyLimit, + dailyLimit, + ); + const candidates = scopeCandidates({ + tenantId: input.tenantId, + mergedRules, + scope, + questions: questionScopes, + }); + const staff = await loadStaffMembership(client, input.tenantId, input.userId); + + if (staff && boolValue(mergedRules.staffBypass, true)) { + return { + accessMode: 'staff', + entitlementId: null, + questionIds: input.questionIds, + consumedFreeQuota: 0, + accessSnapshot: { + requiredAccess, + grantedBy: 'staff', + staffRole: staff.role, + requestedCount: input.questionIds.length, + grantedCount: input.questionIds.length, + scope: candidates, + rules: mergedRules, + }, + }; + } + + const entitlement = (await loadActiveEntitlements(client, input.tenantId, input.userId)) + .find(row => hasScopeAccess(row, candidates)); + if (entitlement) { + return { + accessMode: 'svip', + entitlementId: entitlement.id, + questionIds: input.questionIds, + consumedFreeQuota: 0, + accessSnapshot: { + requiredAccess, + grantedBy: 'svip', + requestedCount: input.questionIds.length, + grantedCount: input.questionIds.length, + entitlement: { + id: entitlement.id, + scopeType: entitlement.scopeType, + scopeId: entitlement.scopeId, + expiresAt: entitlement.expiresAt, + }, + scope: candidates, + rules: mergedRules, + }, + }; + } + + if (requiredAccess === 'svip') { + await recordPracticeAccessEvent(client, { + tenantId: input.tenantId, + userId: input.userId, + eventType: 'session_denied', + accessMode: 'denied', + requestedCount: input.questionIds.length, + grantedCount: 0, + consumedFreeQuota: 0, + reason: 'SVIP_REQUIRED', + scopeType: 'tenant', + scopeId: null, + entitlementId: null, + metadata: { requiredAccess, scope: candidates, rules: mergedRules }, + }); + throw new HttpError(403, 'SVIP entitlement is required for this practice target', 'PRACTICE_SVIP_REQUIRED'); + } + + const quotaScopeType = stringValue(mergedRules.freeQuotaScopeType) || 'tenant'; + const quotaScopeId = + quotaScopeType === 'region' + ? candidates.regionIds[0] || null + : quotaScopeType === 'content_entry' + ? input.assembly.entryId + : quotaScopeType === 'content_node' + ? input.assembly.contentNodeId + : quotaScopeType === 'collection' + ? input.assembly.collectionId + : quotaScopeType === 'blueprint' + ? input.assembly.blueprintId + : null; + const cappedRequestCount = Math.min(input.questionIds.length, maxFreePerSession); + const quota = await consumeDailyFreeQuota(client, { + tenantId: input.tenantId, + userId: input.userId, + requestedCount: cappedRequestCount, + dailyLimit, + scopeType: quotaScopeType, + scopeId: quotaScopeId, + metadata: { + source: 'practice_session', + mode: input.assembly.mode, + blueprintId: input.assembly.blueprintId, + collectionId: input.assembly.collectionId, + entryId: input.assembly.entryId, + contentNodeId: input.assembly.contentNodeId, + }, + }); + + if (quota.grantedCount <= 0) { + await recordPracticeAccessEvent(client, { + tenantId: input.tenantId, + userId: input.userId, + eventType: 'session_denied', + accessMode: 'denied', + requestedCount: input.questionIds.length, + grantedCount: 0, + consumedFreeQuota: 0, + reason: 'FREE_DAILY_LIMIT_REACHED', + scopeType: quotaScopeType, + scopeId: quotaScopeId, + entitlementId: null, + metadata: { requiredAccess, dailyLimit, scope: candidates, rules: mergedRules, quota }, + }); + throw new HttpError(403, 'Daily free practice quota is used up', 'PRACTICE_FREE_LIMIT_REACHED'); + } + + const grantedQuestionIds = input.questionIds.slice(0, quota.grantedCount); + return { + accessMode: 'free', + entitlementId: null, + questionIds: grantedQuestionIds, + consumedFreeQuota: quota.consumedFreeQuota, + accessSnapshot: { + requiredAccess, + grantedBy: 'free_quota', + requestedCount: input.questionIds.length, + grantedCount: grantedQuestionIds.length, + truncated: grantedQuestionIds.length < input.questionIds.length, + dailyLimit, + maxFreePerSession, + quotaScopeType, + quotaScopeId, + quota, + scope: candidates, + rules: mergedRules, + }, + }; +} + +export async function recordPracticeAccessEvent(client: pg.PoolClient, input: { + tenantId: string; + userId: string | null; + practiceSessionId?: string | null; + eventType: 'session_created' | 'session_denied' | 'quota_consumed'; + accessMode: 'free' | 'svip' | 'staff' | 'denied'; + requestedCount: number; + grantedCount: number; + consumedFreeQuota: number; + reason?: string | null; + scopeType?: string | null; + scopeId?: string | null; + entitlementId?: string | null; + metadata?: JsonObject; +}) { + await client.query( + ` + insert into public.practice_access_events ( + tenant_id, user_id, practice_session_id, event_type, access_mode, + requested_count, granted_count, consumed_free_quota, reason, + scope_type, scope_id, entitlement_id, metadata + ) + values ($1, $2::uuid, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11::uuid, $12::uuid, $13::jsonb) + `, + [ + input.tenantId, + input.userId, + input.practiceSessionId || null, + input.eventType, + input.accessMode, + input.requestedCount, + input.grantedCount, + input.consumedFreeQuota, + input.reason || null, + input.scopeType || null, + input.scopeId || null, + input.entitlementId || null, + JSON.stringify(input.metadata || {}), + ], + ); +} + +export async function assertAnswerSessionAccess(client: pg.PoolClient, input: { + tenantId: string; + userId: string; + practiceSessionId: string | null; + questionId: string; +}) { + if (!input.practiceSessionId) { + throw new HttpError(400, 'practiceSessionId is required for answer submission', 'PRACTICE_SESSION_REQUIRED'); + } + const result = await client.query<{ id: string }>( + ` + select id + from public.practice_sessions + where tenant_id = $1 + and user_id = $2 + and id = $3 + and finished_at is null + and (expires_at is null or expires_at > now()) + and question_ids ? $4 + limit 1 + `, + [input.tenantId, input.userId, input.practiceSessionId, input.questionId], + ); + if (!result.rows[0]) { + throw new HttpError(403, 'Question is not available in the active practice session', 'PRACTICE_SESSION_QUESTION_FORBIDDEN'); + } +} diff --git a/apps/api/src/features/learning/routes.ts b/apps/api/src/features/learning/routes.ts index 7738c08c..e155f616 100644 --- a/apps/api/src/features/learning/routes.ts +++ b/apps/api/src/features/learning/routes.ts @@ -11,6 +11,7 @@ import { userIdFrom, } from '../../core/request.js'; import { query, queryOne, transaction } from '../../core/db.js'; +import { assertAnswerSessionAccess, authorizePracticeSession, recordPracticeAccessEvent } from './access.js'; interface QuestionAnswerRow { question_id: string; @@ -351,53 +352,86 @@ export async function createPracticeSessionRoute(ctx: RequestContext) { throw new HttpError(409, 'No published questions are available for this practice target', 'NO_PRACTICE_QUESTIONS'); } - const item = await queryOne( - ` - insert into public.practice_sessions ( - tenant_id, user_id, mode, target_type, target_id, - blueprint_id, collection_id, entry_id, content_node_id, - question_ids, question_count, duration_minutes, total_score, - expires_at, metadata - ) - values ( - $1, $2, $3, $4, $5, - $6::uuid, $7::uuid, $8::uuid, $9::uuid, - $10::jsonb, $11, $12, $13, - case when $12::integer is null then null else now() + make_interval(mins => $12::integer) end, - $14::jsonb - ) - returning id, tenant_id as "tenantId", user_id as "userId", mode, - target_type as "targetType", target_id as "targetId", - blueprint_id as "blueprintId", collection_id as "collectionId", - entry_id as "entryId", content_node_id as "contentNodeId", - question_ids as "questionIds", question_count as "questionCount", - duration_minutes as "durationMinutes", total_score as "totalScore", - expires_at as "expiresAt", started_at as "startedAt", - finished_at as "finishedAt", metadata - `, - [ + const item = await transaction(async client => { + const access = await authorizePracticeSession(client, { tenantId, userId, - assembly.mode, - assembly.targetType, - assembly.targetId, - assembly.blueprintId, - assembly.collectionId, - assembly.entryId, - assembly.contentNodeId, - JSON.stringify(questionIds), - questionIds.length, - assembly.durationMinutes, - assembly.totalScore, - JSON.stringify({ - ...(body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {}), - assembly: { - sections: assembly.sections, - rules: assembly.rules, - }, - }), - ], - ); + questionIds, + assembly, + }); + const sessionResult = await client.query( + ` + insert into public.practice_sessions ( + tenant_id, user_id, mode, target_type, target_id, + blueprint_id, collection_id, entry_id, content_node_id, + question_ids, question_count, duration_minutes, total_score, + access_mode, access_entitlement_id, consumed_free_quota, access_snapshot, + expires_at, metadata + ) + values ( + $1, $2, $3, $4, $5::uuid, + $6::uuid, $7::uuid, $8::uuid, $9::uuid, + $10::jsonb, $11, $12, $13, + $14, $15::uuid, $16, $17::jsonb, + case when $12::integer is null then null else now() + make_interval(mins => $12::integer) end, + $18::jsonb + ) + returning id, tenant_id as "tenantId", user_id as "userId", mode, + target_type as "targetType", target_id as "targetId", + blueprint_id as "blueprintId", collection_id as "collectionId", + entry_id as "entryId", content_node_id as "contentNodeId", + question_ids as "questionIds", question_count as "questionCount", + duration_minutes as "durationMinutes", total_score as "totalScore", + access_mode as "accessMode", access_entitlement_id as "accessEntitlementId", + consumed_free_quota as "consumedFreeQuota", access_snapshot as "accessSnapshot", + expires_at as "expiresAt", started_at as "startedAt", + finished_at as "finishedAt", metadata + `, + [ + tenantId, + userId, + assembly.mode, + assembly.targetType, + assembly.targetId, + assembly.blueprintId, + assembly.collectionId, + assembly.entryId, + assembly.contentNodeId, + JSON.stringify(access.questionIds), + access.questionIds.length, + assembly.durationMinutes, + assembly.totalScore, + access.accessMode, + access.entitlementId, + access.consumedFreeQuota, + JSON.stringify(access.accessSnapshot), + JSON.stringify({ + ...(body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {}), + assembly: { + sections: assembly.sections, + rules: assembly.rules, + }, + }), + ], + ); + + await recordPracticeAccessEvent(client, { + tenantId, + userId, + practiceSessionId: sessionResult.rows[0].id, + eventType: 'session_created', + accessMode: access.accessMode, + requestedCount: questionIds.length, + grantedCount: access.questionIds.length, + consumedFreeQuota: access.consumedFreeQuota, + scopeType: access.accessSnapshot.quotaScopeType as string | undefined, + scopeId: access.accessSnapshot.quotaScopeId as string | undefined, + entitlementId: access.entitlementId, + metadata: access.accessSnapshot, + }); + + return sessionResult.rows[0]; + }); return { item }; } @@ -430,6 +464,8 @@ export async function submitAnswerRoute(ctx: RequestContext) { const judged = judgeAnswer(question, selectedOptions, answerText); const result = await transaction(async client => { + await assertAnswerSessionAccess(client, { tenantId, userId, practiceSessionId, questionId }); + const answerResult = await client.query( ` insert into public.answer_records ( diff --git a/apps/api/src/features/tenant-content/navigation.ts b/apps/api/src/features/tenant-content/navigation.ts index 39d53238..fbddb4a9 100644 --- a/apps/api/src/features/tenant-content/navigation.ts +++ b/apps/api/src/features/tenant-content/navigation.ts @@ -244,7 +244,7 @@ export async function contentNodesAdminRoute(ctx: RequestContext) { marker_type as "markerType", marker_config as "markerConfig", path::text as path, depth, sort_order as "order", is_active as "isActive", is_selectable as "isSelectable", - is_leaf as "isLeaf", metadata, created_by as "createdBy", + is_leaf as "isLeaf", access_rules as "accessRules", metadata, created_by as "createdBy", updated_by as "updatedBy", created_at as "createdAt", updated_at as "updatedAt" from public.content_nodes @@ -299,12 +299,12 @@ export async function upsertContentNodeRoute(ctx: RequestContext) { insert into public.content_nodes ( id, tenant_id, entry_id, region_id, parent_id, legacy_id, node_key, name, node_type, marker_type, marker_config, path, depth, sort_order, - is_active, is_selectable, is_leaf, metadata, created_by, updated_by + is_active, is_selectable, is_leaf, access_rules, metadata, created_by, updated_by ) values ( $1, $2, $3, $4::uuid, $5::uuid, $6, $7, $8, $9, $10, $11::jsonb, $12::ltree, $13, $14, - $15, $16, $17, $18::jsonb, $19, $19 + $15, $16, $17, $18::jsonb, $19::jsonb, $20, $20 ) on conflict (id) do update set entry_id = excluded.entry_id, @@ -322,6 +322,7 @@ export async function upsertContentNodeRoute(ctx: RequestContext) { is_active = excluded.is_active, is_selectable = excluded.is_selectable, is_leaf = excluded.is_leaf, + access_rules = excluded.access_rules, metadata = excluded.metadata, updated_by = excluded.updated_by, updated_at = now() @@ -332,7 +333,7 @@ export async function upsertContentNodeRoute(ctx: RequestContext) { marker_type as "markerType", marker_config as "markerConfig", path::text as path, depth, sort_order as "order", is_active as "isActive", is_selectable as "isSelectable", - is_leaf as "isLeaf", metadata, created_at as "createdAt", + is_leaf as "isLeaf", access_rules as "accessRules", metadata, created_at as "createdAt", updated_at as "updatedAt" `, [ @@ -353,6 +354,7 @@ export async function upsertContentNodeRoute(ctx: RequestContext) { boolValue(body.isActive, true), boolValue(body.isSelectable, true), boolValue(body.isLeaf, false), + jsonObjectValue(body.accessRules), jsonObjectValue(body.metadata), auth.userId, ], @@ -418,7 +420,7 @@ export async function questionCollectionsAdminRoute(ctx: RequestContext) { legacy_id as "legacyId", name, collection_type as "collectionType", source_type as "sourceType", filters, question_count as "questionCount", total_score as "totalScore", duration_minutes as "durationMinutes", - status, sort_order as "order", metadata, + status, sort_order as "order", access_rules as "accessRules", metadata, created_by as "createdBy", updated_by as "updatedBy", created_at as "createdAt", updated_at as "updatedAt" from public.question_collections @@ -526,13 +528,13 @@ export async function upsertQuestionCollectionRoute(ctx: RequestContext) { id, tenant_id, region_id, entry_id, node_id, subject_id, category_id, question_bank_id, legacy_id, name, collection_type, source_type, filters, question_count, total_score, duration_minutes, - status, sort_order, metadata, created_by, updated_by + status, sort_order, access_rules, metadata, created_by, updated_by ) values ( coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6::uuid, $7::uuid, $8::uuid, $9, $10, $11, $12, $13::jsonb, $14, $15, $16, - $17, $18, $19::jsonb, $20, $20 + $17, $18, $19::jsonb, $20::jsonb, $21, $21 ) on conflict (id) do update set region_id = excluded.region_id, @@ -551,6 +553,7 @@ export async function upsertQuestionCollectionRoute(ctx: RequestContext) { duration_minutes = excluded.duration_minutes, status = excluded.status, sort_order = excluded.sort_order, + access_rules = excluded.access_rules, metadata = excluded.metadata, updated_by = excluded.updated_by, updated_at = now() @@ -561,7 +564,7 @@ export async function upsertQuestionCollectionRoute(ctx: RequestContext) { legacy_id as "legacyId", name, collection_type as "collectionType", source_type as "sourceType", filters, question_count as "questionCount", total_score as "totalScore", duration_minutes as "durationMinutes", - status, sort_order as "order", metadata, + status, sort_order as "order", access_rules as "accessRules", metadata, created_at as "createdAt", updated_at as "updatedAt" `, [ @@ -583,6 +586,7 @@ export async function upsertQuestionCollectionRoute(ctx: RequestContext) { body.durationMinutes === undefined ? null : intValue(body.durationMinutes, 0), status, intValue(body.order, 0), + jsonObjectValue(body.accessRules), jsonObjectValue(body.metadata), auth.userId, ], @@ -647,7 +651,7 @@ export async function practiceBlueprintsAdminRoute(ctx: RequestContext) { legacy_id as "legacyId", name, mode, assembly_type as "assemblyType", question_limit as "questionLimit", duration_minutes as "durationMinutes", total_score as "totalScore", - pass_score as "passScore", sections, rules, status, + pass_score as "passScore", sections, rules, access_rules as "accessRules", status, sort_order as "order", created_by as "createdBy", updated_by as "updatedBy", created_at as "createdAt", updated_at as "updatedAt" @@ -686,13 +690,13 @@ export async function upsertPracticeBlueprintRoute(ctx: RequestContext) { id, tenant_id, region_id, entry_id, node_id, collection_id, legacy_id, name, mode, assembly_type, question_limit, duration_minutes, total_score, pass_score, sections, rules, - status, sort_order, created_by, updated_by + access_rules, status, sort_order, created_by, updated_by ) values ( coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6::uuid, $7, $8, $9, $10, $11, $12, $13, $14, $15::jsonb, $16::jsonb, - $17, $18, $19, $19 + $17::jsonb, $18, $19, $20, $20 ) on conflict (id) do update set region_id = excluded.region_id, @@ -709,6 +713,7 @@ export async function upsertPracticeBlueprintRoute(ctx: RequestContext) { pass_score = excluded.pass_score, sections = excluded.sections, rules = excluded.rules, + access_rules = excluded.access_rules, status = excluded.status, sort_order = excluded.sort_order, updated_by = excluded.updated_by, @@ -719,7 +724,7 @@ export async function upsertPracticeBlueprintRoute(ctx: RequestContext) { legacy_id as "legacyId", name, mode, assembly_type as "assemblyType", question_limit as "questionLimit", duration_minutes as "durationMinutes", total_score as "totalScore", - pass_score as "passScore", sections, rules, status, + pass_score as "passScore", sections, rules, access_rules as "accessRules", status, sort_order as "order", created_at as "createdAt", updated_at as "updatedAt" `, @@ -740,6 +745,7 @@ export async function upsertPracticeBlueprintRoute(ctx: RequestContext) { body.passScore === undefined || body.passScore === null ? null : Number(body.passScore), jsonArrayValue(body.sections), jsonObjectValue(body.rules), + jsonObjectValue(body.accessRules), status, intValue(body.order, 0), auth.userId, diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 18d812cf..f1a4e051 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -52,11 +52,11 @@ | 任意深度分类树 | 可联调 | `/api/catalog/content-nodes` | | 题目列表/集合 | 可联调 | `/api/catalog/question-collections`、`question-collections/questions` | | 顺序/随机/全真模拟规则 | 可联调 | `/api/catalog/practice-blueprints` | -| 创建练习 session | 可联调 | `POST /api/learning/practice-sessions` | -| 答题记录 | 可联调 | `POST /api/learning/answers` | +| 创建练习 session | 可联调 | `POST /api/learning/practice-sessions`;后端强制校验免费额度、SVIP 范围和内容访问规则 | +| 答题记录 | 可联调 | `POST /api/learning/answers`;题目必须属于本人有效 session 快照 | | 错题本 | 可联调 | `/api/learning/wrong-questions` | | 收藏夹 | 可联调 | `/api/learning/favorites/questions` | -| 免费用户题量限制 | 待补齐 | 旧项目有保护逻辑,新后端需按租户/套餐/内容范围实现 | +| 免费用户题量限制 | 可联调 | `practice_daily_usage` + `practice_access_events`;支持内容 accessRules、每日额度、session 截断、SVIP-only 拦截 | | 模考交卷报告 | 待补齐 | 已有 session/answer 基础,缺完整交卷、评分报告、错题解析汇总 | ## 背单词、知识手册、分数线、视频 diff --git a/docs/refactor/backend-progress.md b/docs/refactor/backend-progress.md index b5d75f67..23a99620 100644 --- a/docs/refactor/backend-progress.md +++ b/docs/refactor/backend-progress.md @@ -17,6 +17,7 @@ - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、激活码批次、优惠券、成员管理、权限矩阵、审计查询。 - `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册 JSON 导入维护。 - `tenant`:域名/租户解析。 +- `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。 - `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。 - 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。 - 已新增 `npm run smoke:core-api`,用于验证个人中心、分数线、题目视频、背单词进度/收藏等学生端核心 API。 @@ -178,6 +179,7 @@ GET /api/tenant-admin/audit-logs - CRM 当前完成配置、密钥入私密表、客资入队和队列查询;真实 webhook 发送、重试、签名在后续 `apps/worker` 中实现。 - 内容资源当前完成台账、租户后台维护、学生端 SVIP 下载权限,以及 `local_dev`、阿里云 OSS、腾讯 COS、Supabase Storage 的上传/下载签名 provider。真实对象存在性校验、PDF 预览渲染、防盗链、水印和大文件上传后 worker 校验仍需继续补。 - 题库内容导航当前以 `content_entries/content_nodes` 为主模型,可表达“入口 -> 多级分类 -> 院校/专业/学科/销售意向标记”;题目集合和练习方式由 `question_collections/practice_blueprints` 管理,练习 session 会保存当次题目 ID 快照。 +- 练习访问控制由 `content_entries/content_nodes/question_collections/practice_blueprints` 的 `accessRules` 合并决定;普通用户消耗 `practice_daily_usage`,事件写入 `practice_access_events`,SVIP/staff 不消耗免费额度。 - 批量导入当前支持题目、单词、知识手册 JSON 预览、逐行 issue、job/item 台账、执行导入、幂等跳过,并可落到新内容入口和分类节点。旧单词模板的 `vocabulary_units_示例数据` / `vocabulary_示例数据`、知识手册的书籍/章节/小节/知识点嵌套结构都由后端规范化。Excel/CSV、分数线/视频导入会继续复用同一套 `content_import_jobs` 管线。 ## 下一步 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index b22d01f2..63034519 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -20,8 +20,8 @@ | 首页/学生看板 | `pages/StudentDashboardNew.tsx` | 部分覆盖 | 品牌、Banner、公告、入口、个人统计有基础;缺完整运营动态/学习任务聚合 | | 题库入口 | `pages/SubjectSelector.tsx`、`RegionArchitectureEditor.tsx` | 已覆盖 | 前端应改接 `content_entries/content_nodes` | | 多级分类树 | 旧 module/subject/category 树 | 已覆盖 | 新后端支持任意深度和 `marker_type`;前端不要写死层级 | -| 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 继续补题量限制、断点续练、更多题型渲染 | -| 随机刷题 | `pages/Quiz.tsx` | 已覆盖 | 已有 blueprint/session 快照,前端需按 mode 调用 | +| 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 免费额度/SVIP 校验已由后端强制;继续补断点续练、更多题型渲染 | +| 随机刷题 | `pages/Quiz.tsx` | 已覆盖 | 已有 blueprint/session 快照和访问控制,前端需按 mode 调用 | | 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | 后端有 blueprint 基础;缺完整交卷报告、排名、复盘 | | 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 后续补错题复习计划 | | 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 | @@ -49,7 +49,7 @@ | 商户收款配置 | 部分覆盖 | 配置 API 有;缺真实支付 provider 和验签 | | 登录配置 | 部分覆盖 | 配置 API 有;缺真实短信/OAuth provider 实现 | | Banner/公告/FAQ/活动 | 已覆盖 | 前端运营后台可以接 | -| SVIP 套餐 | 已覆盖 | 后续补地区/分类/专业增项限制规则 | +| SVIP 套餐 | 部分覆盖 | 地区/科目/题库范围校验已接入练习/资料/视频;后续补分类/专业增项购买和套餐规则 UI | | 优惠券 | 部分覆盖 | 后台配置有;前台兑换、下单抵扣待补 | | 激活码 | 已覆盖 | 批次、生成、兑换主链路已有 | | 勋章管理 | 部分覆盖 | 表结构有 badges/user_badges;缺后台和学生端 API | diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index dc789ed2..22e4a6ff 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -14,6 +14,7 @@ - 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。 - 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。 - 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。 +- 练习访问控制:`practice_daily_usage/practice_access_events` 支持免费每日额度、SVIP 范围校验、SVIP-only 内容拦截和答题 session 快照保护。 - 内容导入:题目、单词、知识手册 JSON 预览、校验、导入、幂等、审计。 - 本地验证:`npm run check:refactor` 已通过。 @@ -76,7 +77,8 @@ - 单题视频和通用知识视频混合推荐。 6. 学习统计 - - 练习历史、正确率趋势、题型分布、错题复习计划。 + - 已完成免费额度和练习访问事件基础。 + - 继续补练习历史、正确率趋势、题型分布、错题复习计划。 - 单词复习算法、每日计划、排行榜。 - 模考交卷、评分报告、错题解析汇总。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 6f947359..e317c5bf 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -152,6 +152,51 @@ tenant::theme | 个人中心 | `GET/PATCH /api/profile/me` | | 销售分享 | `/api/referral/resolve`、`track-event`、`bind` | +## 练习访问控制契约 + +前端不要先拉完整题目列表再自行判断免费额度。用户点击顺序刷题、随机刷题、全真模拟时,统一调用 `POST /api/learning/practice-sessions`,后端会根据 `content_entries.accessRules`、`content_nodes.accessRules`、`question_collections.accessRules`、`practice_blueprints.accessRules` 和当前用户权益决定最终题目快照。 + +请求示例: + +```json +{ + "mode": "sequential", + "collectionId": "00000000-0000-0000-0000-000000000615", + "questionLimit": 50 +} +``` + +响应关键字段: + +```json +{ + "item": { + "id": "...", + "mode": "sequential", + "questionIds": ["..."], + "questionCount": 25, + "accessMode": "free", + "consumedFreeQuota": 25, + "accessSnapshot": { + "grantedBy": "free_quota", + "requestedCount": 50, + "grantedCount": 25, + "truncated": true, + "dailyLimit": 25 + } + } +} +``` + +前端处理规则: + +- 以返回的 `questionIds` 为准渲染本次练习,不要自行追加题目。 +- `accessSnapshot.truncated=true` 时,可提示“今日免费额度有限,已为你开放 N 题”并引导开通 SVIP。 +- `PRACTICE_FREE_LIMIT_REACHED`:弹出会员购买/激活码兑换入口。 +- `PRACTICE_SVIP_REQUIRED`:提示该内容需要对应地区/科目/题库 SVIP。 +- `PRACTICE_SESSION_QUESTION_FORBIDDEN`:说明提交答案的题目不在本次 session 快照内,应清理本地异常进度并重新开始。 +- 提交答案必须传 `practiceSessionId`;后端会拒绝不属于本人有效 session 的题目。 + ## 视频播放契约 题目视频分为 `free`、`svip`、`video_quota` 三种访问模式。列表接口只用于展示标题、封面、时长、访问模式和试看秒数;除免费公开视频外,列表和搜索接口不会返回可播放 URL。 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 1617dcf1..92a9fc47 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -28,6 +28,8 @@ const ids = { practiceBlueprintRandom: '00000000-0000-0000-0000-000000000617', practiceBlueprintMock: '00000000-0000-0000-0000-000000000618', question: '00000000-0000-0000-0000-000000000401', + questionTwo: '00000000-0000-0000-0000-000000000403', + questionThree: '00000000-0000-0000-0000-000000000405', vocabularyUnit: '00000000-0000-0000-0000-000000000811', vocabularyWord: '00000000-0000-0000-0000-000000000812', video: '00000000-0000-0000-0000-000000000821', @@ -565,28 +567,80 @@ async function testCatalogAndLearning() { }); assert.ok(questionsByNode.items?.some(item => item.id === ids.question), 'catalog questions should filter by contentNodeId'); - const session = await request('/api/learning/practice-sessions', { - method: 'POST', - body: { userId: USER_ID, mode: 'chapter', targetType: 'category', targetId: ids.question }, - }); - assert.ok(session.item?.id, 'practice session should be created'); - - const sequentialSession = await request('/api/learning/practice-sessions', { + const freeLogin = await loginBySms('13800000008'); + const freeSession = await request('/api/learning/practice-sessions', { + userId: false, + headers: { authorization: `Bearer ${freeLogin.session.token}` }, method: 'POST', body: { - userId: USER_ID, mode: 'sequential', collectionId: ids.questionCollection, questionLimit: 5, }, }); - assert.equal(sequentialSession.item?.collectionId, ids.questionCollection, 'collection session should bind collection'); - assert.ok(sequentialSession.item?.questionIds?.includes(ids.question), 'collection session should snapshot question ids'); + assert.equal(freeSession.item?.collectionId, ids.questionCollection, 'collection session should bind collection'); + assert.equal(freeSession.item?.accessMode, 'free', 'non-SVIP user should use free quota'); + assert.equal(freeSession.item?.consumedFreeQuota, 2, 'free practice should consume configured daily quota'); + assert.equal(freeSession.item?.questionCount, 2, 'free practice should be truncated to configured free limit'); + assert.equal(freeSession.item?.accessSnapshot?.truncated, true, 'free practice access snapshot should mark truncation'); + assert.ok(freeSession.item?.questionIds?.includes(ids.question), 'free session should snapshot first question'); - const nodeSession = await request('/api/learning/practice-sessions', { + const freeLimitReached = await request('/api/learning/practice-sessions', { + userId: false, + headers: { authorization: `Bearer ${freeLogin.session.token}` }, method: 'POST', body: { - userId: USER_ID, + mode: 'sequential', + collectionId: ids.questionCollection, + questionLimit: 5, + }, + expectStatus: 403, + }); + assert.equal(freeLimitReached.code, 'PRACTICE_FREE_LIMIT_REACHED', 'second free session should be blocked after quota is exhausted'); + + const forbiddenAnswer = await request('/api/learning/answers', { + userId: false, + headers: { authorization: `Bearer ${freeLogin.session.token}` }, + method: 'POST', + body: { + questionId: ids.questionThree, + selectedOptions: ['1'], + practiceSessionId: freeSession.item.id, + }, + expectStatus: 403, + }); + assert.equal(forbiddenAnswer.code, 'PRACTICE_SESSION_QUESTION_FORBIDDEN', 'answers outside the session snapshot should be rejected'); + + const answer = await request('/api/learning/answers', { + userId: false, + headers: { authorization: `Bearer ${freeLogin.session.token}` }, + method: 'POST', + body: { + questionId: ids.question, + selectedOptions: ['0'], + practiceSessionId: freeSession.item.id, + }, + }); + assert.equal(answer.item?.isCorrect, false, 'wrong answer should be judged false'); + + const staffSequentialSession = await request('/api/learning/practice-sessions', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + userId: TENANT_ADMIN_USER_ID, + mode: 'sequential', + collectionId: ids.questionCollection, + questionLimit: 5, + }, + }); + assert.equal(staffSequentialSession.item?.accessMode, 'staff', 'tenant staff should bypass free practice quota'); + assert.ok(staffSequentialSession.item?.questionIds?.includes(ids.questionThree), 'staff session should receive the full collection'); + + const nodeSession = await request('/api/learning/practice-sessions', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + userId: TENANT_ADMIN_USER_ID, mode: 'random', contentNodeId: ids.contentNodeProfessional, questionLimit: 5, @@ -596,9 +650,10 @@ async function testCatalogAndLearning() { assert.ok(nodeSession.item?.questionIds?.includes(ids.question), 'node session should include descendant questions'); const mockSession = await request('/api/learning/practice-sessions', { + userId: TENANT_ADMIN_USER_ID, method: 'POST', body: { - userId: USER_ID, + userId: TENANT_ADMIN_USER_ID, blueprintId: ids.practiceBlueprintMock, }, }); @@ -608,18 +663,11 @@ async function testCatalogAndLearning() { assert.equal(Number(mockSession.item?.totalScore), 100, 'mock session should inherit total score'); assert.ok(mockSession.item?.questionIds?.includes(ids.question), 'mock session should snapshot assembled questions'); - const answer = await request('/api/learning/answers', { - method: 'POST', - body: { - userId: USER_ID, - questionId: ids.question, - selectedOptions: ['0'], - practiceSessionId: session.item.id, - }, + const wrong = await request('/api/learning/wrong-questions', { + userId: false, + headers: { authorization: `Bearer ${freeLogin.session.token}` }, + query: { status: 'all' }, }); - assert.equal(answer.item?.isCorrect, false, 'wrong answer should be judged false'); - - const wrong = await request('/api/learning/wrong-questions', { query: { status: 'all' } }); assert.ok(wrong.items?.some(item => item.questionId === ids.question), 'wrong book should include smoke question'); } diff --git a/scripts/smoke-seed.js b/scripts/smoke-seed.js index 484cb301..fdde2505 100644 --- a/scripts/smoke-seed.js +++ b/scripts/smoke-seed.js @@ -25,6 +25,10 @@ const ids = { questionBank: '00000000-0000-0000-0000-000000000400', question: '00000000-0000-0000-0000-000000000401', questionVersion: '00000000-0000-0000-0000-000000000402', + questionTwo: '00000000-0000-0000-0000-000000000403', + questionTwoVersion: '00000000-0000-0000-0000-000000000404', + questionThree: '00000000-0000-0000-0000-000000000405', + questionThreeVersion: '00000000-0000-0000-0000-000000000406', plan: '00000000-0000-0000-0000-000000000201', order: '00000000-0000-0000-0000-000000000701', payment: '00000000-0000-0000-0000-000000000702', @@ -73,6 +77,97 @@ async function main() { [tenantId, ids.user, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser], ); + await client.query( + ` + with transient_users as ( + select u.id + from public.platform_users u + where u.phone in ('13800000006', '13800000007', '13800000008') + ) + delete from public.practice_access_events + where tenant_id = $1 + and user_id in (select id from transient_users) + `, + [tenantId], + ); + + await client.query( + ` + with transient_users as ( + select u.id + from public.platform_users u + where u.phone in ('13800000006', '13800000007', '13800000008') + ) + delete from public.practice_daily_usage + where tenant_id = $1 + and user_id in (select id from transient_users) + `, + [tenantId], + ); + + await client.query( + ` + with transient_users as ( + select u.id + from public.platform_users u + where u.phone in ('13800000006', '13800000007', '13800000008') + ) + delete from public.answer_records + where tenant_id = $1 + and user_id in (select id from transient_users) + `, + [tenantId], + ); + + await client.query( + ` + with transient_users as ( + select u.id + from public.platform_users u + where u.phone in ('13800000006', '13800000007', '13800000008') + ) + delete from public.practice_sessions + where tenant_id = $1 + and user_id in (select id from transient_users) + `, + [tenantId], + ); + + await client.query( + ` + with transient_users as ( + select u.id + from public.platform_users u + where u.phone in ('13800000006', '13800000007', '13800000008') + ) + delete from app_private.auth_sessions + where tenant_id = $1 + and user_id in (select id from transient_users) + `, + [tenantId], + ); + + await client.query( + ` + with transient_users as ( + select u.id + from public.platform_users u + where u.phone in ('13800000006', '13800000007', '13800000008') + ) + delete from public.user_identities + where user_id in (select id from transient_users) + `, + ); + + await client.query( + ` + delete from public.sms_verification_codes + where tenant_id = $1 + and phone in ('13800000006', '13800000007', '13800000008') + `, + [tenantId], + ); + await client.query( ` delete from public.referral_qrcodes @@ -328,13 +423,14 @@ async function main() { id, tenant_id, region_id, entry_id, node_id, subject_id, category_id, question_bank_id, legacy_id, name, collection_type, source_type, filters, question_count, total_score, duration_minutes, status, - sort_order, metadata, created_by, updated_by + sort_order, access_rules, metadata, created_by, updated_by ) values ( $1, $2, $3, $4, $5, $6, $7, $8, 'smoke-collection', '烟测学院专业课题目列表', 'manual', 'manual_questions', '{"tabs":["all","paper","chapter","type"]}'::jsonb, 0, 100, 120, 'active', - 1, '{"business":"supports sequential random mock exam"}'::jsonb, $9, $9 + 1, '{"freeDailyLimit":2,"freeSessionLimit":2,"freeQuotaScopeType":"tenant"}'::jsonb, + '{"business":"supports sequential random mock exam"}'::jsonb, $9, $9 ) on conflict (id) do update set node_id = excluded.node_id, @@ -348,6 +444,7 @@ async function main() { total_score = excluded.total_score, duration_minutes = excluded.duration_minutes, status = 'active', + access_rules = excluded.access_rules, updated_at = now() `, [ @@ -370,11 +467,22 @@ async function main() { entry_id, content_node_id, primary_collection_id, legacy_id, type, type_label, difficulty, status, exam_markers ) - values ( - $1, $2, $3, $4, $5, $6, $7, $8, - 'smoke-question', 'choice', '单选题', 1, 'published', - '{"examTrack":"professional","school":"烟测学院"}'::jsonb - ) + values + ( + $1, $2, $5, $6, $7, $8, $9, $10, + 'smoke-question', 'choice', '单选题', 1, 'published', + '{"examTrack":"professional","school":"烟测学院"}'::jsonb + ), + ( + $3, $2, $5, $6, $7, $8, $9, $10, + 'smoke-question-2', 'choice', '单选题', 1, 'published', + '{"examTrack":"professional","school":"烟测学院"}'::jsonb + ), + ( + $4, $2, $5, $6, $7, $8, $9, $10, + 'smoke-question-3', 'choice', '单选题', 1, 'published', + '{"examTrack":"professional","school":"烟测学院"}'::jsonb + ) on conflict (id) do update set question_bank_id = excluded.question_bank_id, subject_id = excluded.subject_id, @@ -388,6 +496,8 @@ async function main() { [ ids.question, tenantId, + ids.questionTwo, + ids.questionThree, ids.questionBank, ids.subject, ids.category, @@ -402,14 +512,17 @@ async function main() { insert into public.question_collection_items ( tenant_id, collection_id, question_id, section_key, sort_order, score, required, metadata ) - values ($1, $2, $3, 'choice', 1, 2, true, '{"source":"smoke-seed"}'::jsonb) + values + ($1, $2, $3, 'choice', 1, 2, true, '{"source":"smoke-seed"}'::jsonb), + ($1, $2, $4, 'choice', 2, 2, true, '{"source":"smoke-seed"}'::jsonb), + ($1, $2, $5, 'choice', 3, 2, true, '{"source":"smoke-seed"}'::jsonb) on conflict (tenant_id, collection_id, question_id) do update set section_key = excluded.section_key, sort_order = excluded.sort_order, score = excluded.score, updated_at = now() `, - [tenantId, ids.questionCollection, ids.question], + [tenantId, ids.questionCollection, ids.question, ids.questionTwo, ids.questionThree], ); await client.query( @@ -487,11 +600,22 @@ async function main() { id, tenant_id, question_id, version_no, content, options, correct_option_index, correct_option_indices, answer_text, explanation ) - values ( - $1, $2, $3, 1, '1 + 1 = ?', - '[{"label":"A","text":"1"},{"label":"B","text":"2"},{"label":"C","text":"3"}]'::jsonb, - 1, '[1]'::jsonb, '2', '基础加法。' - ) + values + ( + $1, $2, $4, 1, '1 + 1 = ?', + '[{"label":"A","text":"1"},{"label":"B","text":"2"},{"label":"C","text":"3"}]'::jsonb, + 1, '[1]'::jsonb, '2', '基础加法。' + ), + ( + $3, $2, $5, 1, '2 + 2 = ?', + '[{"label":"A","text":"3"},{"label":"B","text":"4"},{"label":"C","text":"5"}]'::jsonb, + 1, '[1]'::jsonb, '4', '基础加法。' + ), + ( + $6, $2, $7, 1, '3 + 3 = ?', + '[{"label":"A","text":"5"},{"label":"B","text":"6"},{"label":"C","text":"7"}]'::jsonb, + 1, '[1]'::jsonb, '6', '基础加法。' + ) on conflict (question_id, version_no) do update set content = excluded.content, options = excluded.options, @@ -500,16 +624,31 @@ async function main() { answer_text = excluded.answer_text, explanation = excluded.explanation `, - [ids.questionVersion, tenantId, ids.question], + [ids.questionVersion, tenantId, ids.questionTwoVersion, ids.question, ids.questionTwo, ids.questionThreeVersion, ids.questionThree], ); await client.query( ` - update public.questions - set current_version_id = $3, has_video_explanation = true, updated_at = now() - where tenant_id = $1 and id = $2 + update public.questions q + set current_version_id = v.version_id, + has_video_explanation = case when q.id = $2::uuid then true else q.has_video_explanation end, + updated_at = now() + from (values + ($2::uuid, $3::uuid), + ($4::uuid, $5::uuid), + ($6::uuid, $7::uuid) + ) as v(question_id, version_id) + where q.tenant_id = $1 and q.id = v.question_id `, - [tenantId, ids.question, ids.questionVersion], + [ + tenantId, + ids.question, + ids.questionVersion, + ids.questionTwo, + ids.questionTwoVersion, + ids.questionThree, + ids.questionThreeVersion, + ], ); await client.query( diff --git a/supabase/migrations/202606210011_practice_access_control.sql b/supabase/migrations/202606210011_practice_access_control.sql new file mode 100644 index 00000000..d7a47c3c --- /dev/null +++ b/supabase/migrations/202606210011_practice_access_control.sql @@ -0,0 +1,95 @@ +alter table public.content_nodes + add column if not exists access_rules jsonb not null default '{}'::jsonb; + +alter table public.question_collections + add column if not exists access_rules jsonb not null default '{}'::jsonb; + +alter table public.practice_blueprints + add column if not exists access_rules jsonb not null default '{}'::jsonb; + +alter table public.practice_sessions + add column if not exists access_mode text not null default 'free', + add column if not exists access_entitlement_id uuid references public.entitlements(id) on delete set null, + add column if not exists consumed_free_quota integer not null default 0, + add column if not exists access_snapshot jsonb not null default '{}'::jsonb; + +do $$ +begin + if not exists (select 1 from pg_constraint where conname = 'practice_sessions_access_mode_check') then + alter table public.practice_sessions + add constraint practice_sessions_access_mode_check + check (access_mode in ('free', 'svip', 'staff')); + end if; + + if not exists (select 1 from pg_constraint where conname = 'practice_sessions_consumed_free_quota_check') then + alter table public.practice_sessions + add constraint practice_sessions_consumed_free_quota_check + check (consumed_free_quota >= 0); + end if; +end $$; + +create table if not exists public.practice_daily_usage ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + user_id uuid not null references public.platform_users(id) on delete cascade, + usage_date date not null default current_date, + scope_type text not null default 'tenant' + check (scope_type in ('tenant', 'region', 'subject', 'question_bank', 'content_entry', 'content_node', 'collection', 'blueprint')), + scope_id uuid, + free_limit integer not null default 25 check (free_limit >= 0), + used_count integer not null default 0 check (used_count >= 0), + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.practice_access_events ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + user_id uuid references public.platform_users(id) on delete set null, + practice_session_id uuid references public.practice_sessions(id) on delete set null, + event_type text not null + check (event_type in ('session_created', 'session_denied', 'quota_consumed')), + access_mode text not null default 'free' + check (access_mode in ('free', 'svip', 'staff', 'denied')), + requested_count integer not null default 0 check (requested_count >= 0), + granted_count integer not null default 0 check (granted_count >= 0), + consumed_free_quota integer not null default 0 check (consumed_free_quota >= 0), + reason text, + scope_type text, + scope_id uuid, + entitlement_id uuid references public.entitlements(id) on delete set null, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +create index if not exists idx_practice_daily_usage_user + on public.practice_daily_usage(tenant_id, user_id, usage_date desc); + +create unique index if not exists idx_practice_daily_usage_unique_scope + on public.practice_daily_usage(tenant_id, user_id, usage_date, scope_type, scope_id) + nulls not distinct; + +create index if not exists idx_practice_access_events_user + on public.practice_access_events(tenant_id, user_id, created_at desc); + +create index if not exists idx_practice_access_events_session + on public.practice_access_events(tenant_id, practice_session_id); + +do $$ +declare + table_name text; +begin + foreach table_name in array array['practice_daily_usage', 'practice_access_events'] + loop + execute format('alter table public.%I enable row level security', table_name); + execute format('drop policy if exists tenant_isolation on public.%I', table_name); + execute format( + 'create policy tenant_isolation on public.%I for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin())', + table_name + ); + execute format('drop trigger if exists set_updated_at on public.%I', table_name); + end loop; + + execute 'create trigger set_updated_at before update on public.practice_daily_usage for each row execute function app.touch_updated_at()'; +end $$;