feat: enforce practice access controls

This commit is contained in:
Codex
2026-06-28 23:23:02 +08:00
parent 3647c2bc2d
commit 6a7294224d
12 changed files with 1047 additions and 110 deletions

View File

@@ -84,7 +84,7 @@ export async function contentNodesRoute(ctx: RequestContext) {
marker_type as "markerType", marker_config as "markerConfig", marker_type as "markerType", marker_config as "markerConfig",
path::text as path, depth, sort_order as "order", path::text as path, depth, sort_order as "order",
is_active as "isActive", is_selectable as "isSelectable", 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" created_at as "createdAt", updated_at as "updatedAt"
from public.content_nodes from public.content_nodes
where ${filters.join(' and ')} where ${filters.join(' and ')}
@@ -132,7 +132,7 @@ export async function questionCollectionsRoute(ctx: RequestContext) {
legacy_id as "legacyId", name, collection_type as "collectionType", legacy_id as "legacyId", name, collection_type as "collectionType",
source_type as "sourceType", filters, question_count as "questionCount", source_type as "sourceType", filters, question_count as "questionCount",
total_score as "totalScore", duration_minutes as "durationMinutes", 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" created_at as "createdAt", updated_at as "updatedAt"
from public.question_collections from public.question_collections
where ${filters.join(' and ')} where ${filters.join(' and ')}
@@ -180,7 +180,7 @@ export async function practiceBlueprintsRoute(ctx: RequestContext) {
legacy_id as "legacyId", name, mode, legacy_id as "legacyId", name, mode,
assembly_type as "assemblyType", question_limit as "questionLimit", assembly_type as "assemblyType", question_limit as "questionLimit",
duration_minutes as "durationMinutes", total_score as "totalScore", 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", sort_order as "order", created_at as "createdAt",
updated_at as "updatedAt" updated_at as "updatedAt"
from public.practice_blueprints from public.practice_blueprints

View File

@@ -0,0 +1,564 @@
import type pg from 'pg';
import { HttpError } from '../../core/http.js';
type JsonObject = Record<string, unknown>;
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<JsonObject>((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<string>();
const subjectIds = new Set<string>();
const questionBankIds = new Set<string>();
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<typeof scopeCandidates>) {
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<PracticeAccessScopeRow>(
`
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<QuestionScopeRow>(
`
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<EntitlementRow>(
`
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<StaffRow>(
`
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<PracticeAccessDecision> {
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');
}
}

View File

@@ -11,6 +11,7 @@ import {
userIdFrom, userIdFrom,
} from '../../core/request.js'; } from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js'; import { query, queryOne, transaction } from '../../core/db.js';
import { assertAnswerSessionAccess, authorizePracticeSession, recordPracticeAccessEvent } from './access.js';
interface QuestionAnswerRow { interface QuestionAnswerRow {
question_id: string; 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'); throw new HttpError(409, 'No published questions are available for this practice target', 'NO_PRACTICE_QUESTIONS');
} }
const item = await queryOne( const item = await transaction(async client => {
` const access = await authorizePracticeSession(client, {
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
`,
[
tenantId, tenantId,
userId, userId,
assembly.mode, questionIds,
assembly.targetType, assembly,
assembly.targetId, });
assembly.blueprintId, const sessionResult = await client.query(
assembly.collectionId, `
assembly.entryId, insert into public.practice_sessions (
assembly.contentNodeId, tenant_id, user_id, mode, target_type, target_id,
JSON.stringify(questionIds), blueprint_id, collection_id, entry_id, content_node_id,
questionIds.length, question_ids, question_count, duration_minutes, total_score,
assembly.durationMinutes, access_mode, access_entitlement_id, consumed_free_quota, access_snapshot,
assembly.totalScore, expires_at, metadata
JSON.stringify({ )
...(body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {}), values (
assembly: { $1, $2, $3, $4, $5::uuid,
sections: assembly.sections, $6::uuid, $7::uuid, $8::uuid, $9::uuid,
rules: assembly.rules, $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 }; return { item };
} }
@@ -430,6 +464,8 @@ export async function submitAnswerRoute(ctx: RequestContext) {
const judged = judgeAnswer(question, selectedOptions, answerText); const judged = judgeAnswer(question, selectedOptions, answerText);
const result = await transaction(async client => { const result = await transaction(async client => {
await assertAnswerSessionAccess(client, { tenantId, userId, practiceSessionId, questionId });
const answerResult = await client.query( const answerResult = await client.query(
` `
insert into public.answer_records ( insert into public.answer_records (

View File

@@ -244,7 +244,7 @@ export async function contentNodesAdminRoute(ctx: RequestContext) {
marker_type as "markerType", marker_config as "markerConfig", marker_type as "markerType", marker_config as "markerConfig",
path::text as path, depth, sort_order as "order", path::text as path, depth, sort_order as "order",
is_active as "isActive", is_selectable as "isSelectable", 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_by as "updatedBy", created_at as "createdAt",
updated_at as "updatedAt" updated_at as "updatedAt"
from public.content_nodes from public.content_nodes
@@ -299,12 +299,12 @@ export async function upsertContentNodeRoute(ctx: RequestContext) {
insert into public.content_nodes ( insert into public.content_nodes (
id, tenant_id, entry_id, region_id, parent_id, legacy_id, node_key, id, tenant_id, entry_id, region_id, parent_id, legacy_id, node_key,
name, node_type, marker_type, marker_config, path, depth, sort_order, 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 ( values (
$1, $2, $3, $4::uuid, $5::uuid, $6, $7, $1, $2, $3, $4::uuid, $5::uuid, $6, $7,
$8, $9, $10, $11::jsonb, $12::ltree, $13, $14, $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) on conflict (id)
do update set entry_id = excluded.entry_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_active = excluded.is_active,
is_selectable = excluded.is_selectable, is_selectable = excluded.is_selectable,
is_leaf = excluded.is_leaf, is_leaf = excluded.is_leaf,
access_rules = excluded.access_rules,
metadata = excluded.metadata, metadata = excluded.metadata,
updated_by = excluded.updated_by, updated_by = excluded.updated_by,
updated_at = now() updated_at = now()
@@ -332,7 +333,7 @@ export async function upsertContentNodeRoute(ctx: RequestContext) {
marker_type as "markerType", marker_config as "markerConfig", marker_type as "markerType", marker_config as "markerConfig",
path::text as path, depth, sort_order as "order", path::text as path, depth, sort_order as "order",
is_active as "isActive", is_selectable as "isSelectable", 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" updated_at as "updatedAt"
`, `,
[ [
@@ -353,6 +354,7 @@ export async function upsertContentNodeRoute(ctx: RequestContext) {
boolValue(body.isActive, true), boolValue(body.isActive, true),
boolValue(body.isSelectable, true), boolValue(body.isSelectable, true),
boolValue(body.isLeaf, false), boolValue(body.isLeaf, false),
jsonObjectValue(body.accessRules),
jsonObjectValue(body.metadata), jsonObjectValue(body.metadata),
auth.userId, auth.userId,
], ],
@@ -418,7 +420,7 @@ export async function questionCollectionsAdminRoute(ctx: RequestContext) {
legacy_id as "legacyId", name, collection_type as "collectionType", legacy_id as "legacyId", name, collection_type as "collectionType",
source_type as "sourceType", filters, question_count as "questionCount", source_type as "sourceType", filters, question_count as "questionCount",
total_score as "totalScore", duration_minutes as "durationMinutes", 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_by as "createdBy", updated_by as "updatedBy",
created_at as "createdAt", updated_at as "updatedAt" created_at as "createdAt", updated_at as "updatedAt"
from public.question_collections 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, id, tenant_id, region_id, entry_id, node_id, subject_id,
category_id, question_bank_id, legacy_id, name, collection_type, category_id, question_bank_id, legacy_id, name, collection_type,
source_type, filters, question_count, total_score, duration_minutes, 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 ( values (
coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6::uuid, coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6::uuid,
$7::uuid, $8::uuid, $9, $10, $11, $7::uuid, $8::uuid, $9, $10, $11,
$12, $13::jsonb, $14, $15, $16, $12, $13::jsonb, $14, $15, $16,
$17, $18, $19::jsonb, $20, $20 $17, $18, $19::jsonb, $20::jsonb, $21, $21
) )
on conflict (id) on conflict (id)
do update set region_id = excluded.region_id, do update set region_id = excluded.region_id,
@@ -551,6 +553,7 @@ export async function upsertQuestionCollectionRoute(ctx: RequestContext) {
duration_minutes = excluded.duration_minutes, duration_minutes = excluded.duration_minutes,
status = excluded.status, status = excluded.status,
sort_order = excluded.sort_order, sort_order = excluded.sort_order,
access_rules = excluded.access_rules,
metadata = excluded.metadata, metadata = excluded.metadata,
updated_by = excluded.updated_by, updated_by = excluded.updated_by,
updated_at = now() updated_at = now()
@@ -561,7 +564,7 @@ export async function upsertQuestionCollectionRoute(ctx: RequestContext) {
legacy_id as "legacyId", name, collection_type as "collectionType", legacy_id as "legacyId", name, collection_type as "collectionType",
source_type as "sourceType", filters, question_count as "questionCount", source_type as "sourceType", filters, question_count as "questionCount",
total_score as "totalScore", duration_minutes as "durationMinutes", 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" 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), body.durationMinutes === undefined ? null : intValue(body.durationMinutes, 0),
status, status,
intValue(body.order, 0), intValue(body.order, 0),
jsonObjectValue(body.accessRules),
jsonObjectValue(body.metadata), jsonObjectValue(body.metadata),
auth.userId, auth.userId,
], ],
@@ -647,7 +651,7 @@ export async function practiceBlueprintsAdminRoute(ctx: RequestContext) {
legacy_id as "legacyId", name, mode, legacy_id as "legacyId", name, mode,
assembly_type as "assemblyType", question_limit as "questionLimit", assembly_type as "assemblyType", question_limit as "questionLimit",
duration_minutes as "durationMinutes", total_score as "totalScore", 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", sort_order as "order", created_by as "createdBy",
updated_by as "updatedBy", created_at as "createdAt", updated_by as "updatedBy", created_at as "createdAt",
updated_at as "updatedAt" 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, id, tenant_id, region_id, entry_id, node_id, collection_id,
legacy_id, name, mode, assembly_type, question_limit, legacy_id, name, mode, assembly_type, question_limit,
duration_minutes, total_score, pass_score, sections, rules, 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 ( values (
coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6::uuid, coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6::uuid,
$7, $8, $9, $10, $11, $7, $8, $9, $10, $11,
$12, $13, $14, $15::jsonb, $16::jsonb, $12, $13, $14, $15::jsonb, $16::jsonb,
$17, $18, $19, $19 $17::jsonb, $18, $19, $20, $20
) )
on conflict (id) on conflict (id)
do update set region_id = excluded.region_id, do update set region_id = excluded.region_id,
@@ -709,6 +713,7 @@ export async function upsertPracticeBlueprintRoute(ctx: RequestContext) {
pass_score = excluded.pass_score, pass_score = excluded.pass_score,
sections = excluded.sections, sections = excluded.sections,
rules = excluded.rules, rules = excluded.rules,
access_rules = excluded.access_rules,
status = excluded.status, status = excluded.status,
sort_order = excluded.sort_order, sort_order = excluded.sort_order,
updated_by = excluded.updated_by, updated_by = excluded.updated_by,
@@ -719,7 +724,7 @@ export async function upsertPracticeBlueprintRoute(ctx: RequestContext) {
legacy_id as "legacyId", name, mode, legacy_id as "legacyId", name, mode,
assembly_type as "assemblyType", question_limit as "questionLimit", assembly_type as "assemblyType", question_limit as "questionLimit",
duration_minutes as "durationMinutes", total_score as "totalScore", 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", sort_order as "order", created_at as "createdAt",
updated_at as "updatedAt" updated_at as "updatedAt"
`, `,
@@ -740,6 +745,7 @@ export async function upsertPracticeBlueprintRoute(ctx: RequestContext) {
body.passScore === undefined || body.passScore === null ? null : Number(body.passScore), body.passScore === undefined || body.passScore === null ? null : Number(body.passScore),
jsonArrayValue(body.sections), jsonArrayValue(body.sections),
jsonObjectValue(body.rules), jsonObjectValue(body.rules),
jsonObjectValue(body.accessRules),
status, status,
intValue(body.order, 0), intValue(body.order, 0),
auth.userId, auth.userId,

View File

@@ -52,11 +52,11 @@
| 任意深度分类树 | 可联调 | `/api/catalog/content-nodes` | | 任意深度分类树 | 可联调 | `/api/catalog/content-nodes` |
| 题目列表/集合 | 可联调 | `/api/catalog/question-collections``question-collections/questions` | | 题目列表/集合 | 可联调 | `/api/catalog/question-collections``question-collections/questions` |
| 顺序/随机/全真模拟规则 | 可联调 | `/api/catalog/practice-blueprints` | | 顺序/随机/全真模拟规则 | 可联调 | `/api/catalog/practice-blueprints` |
| 创建练习 session | 可联调 | `POST /api/learning/practice-sessions` | | 创建练习 session | 可联调 | `POST /api/learning/practice-sessions`后端强制校验免费额度、SVIP 范围和内容访问规则 |
| 答题记录 | 可联调 | `POST /api/learning/answers` | | 答题记录 | 可联调 | `POST /api/learning/answers`;题目必须属于本人有效 session 快照 |
| 错题本 | 可联调 | `/api/learning/wrong-questions` | | 错题本 | 可联调 | `/api/learning/wrong-questions` |
| 收藏夹 | 可联调 | `/api/learning/favorites/questions` | | 收藏夹 | 可联调 | `/api/learning/favorites/questions` |
| 免费用户题量限制 | 待补齐 | 旧项目有保护逻辑,新后端需按租户/套餐/内容范围实现 | | 免费用户题量限制 | 可联调 | `practice_daily_usage` + `practice_access_events`;支持内容 accessRules、每日额度、session 截断、SVIP-only 拦截 |
| 模考交卷报告 | 待补齐 | 已有 session/answer 基础,缺完整交卷、评分报告、错题解析汇总 | | 模考交卷报告 | 待补齐 | 已有 session/answer 基础,缺完整交卷、评分报告、错题解析汇总 |
## 背单词、知识手册、分数线、视频 ## 背单词、知识手册、分数线、视频

View File

@@ -17,6 +17,7 @@
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、激活码批次、优惠券、成员管理、权限矩阵、审计查询。 - `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、激活码批次、优惠券、成员管理、权限矩阵、审计查询。
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册 JSON 导入维护。 - `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册 JSON 导入维护。
- `tenant`:域名/租户解析。 - `tenant`:域名/租户解析。
- `learning` 已接入商用访问控制免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。
- `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。 - `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。
- 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。 - 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。
- 已新增 `npm run smoke:core-api`,用于验证个人中心、分数线、题目视频、背单词进度/收藏等学生端核心 API。 - 已新增 `npm run smoke:core-api`,用于验证个人中心、分数线、题目视频、背单词进度/收藏等学生端核心 API。
@@ -178,6 +179,7 @@ GET /api/tenant-admin/audit-logs
- CRM 当前完成配置、密钥入私密表、客资入队和队列查询;真实 webhook 发送、重试、签名在后续 `apps/worker` 中实现。 - CRM 当前完成配置、密钥入私密表、客资入队和队列查询;真实 webhook 发送、重试、签名在后续 `apps/worker` 中实现。
- 内容资源当前完成台账、租户后台维护、学生端 SVIP 下载权限,以及 `local_dev`、阿里云 OSS、腾讯 COS、Supabase Storage 的上传/下载签名 provider。真实对象存在性校验、PDF 预览渲染、防盗链、水印和大文件上传后 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` 管理,练习 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` 管线。 - 批量导入当前支持题目、单词、知识手册 JSON 预览、逐行 issue、job/item 台账、执行导入、幂等跳过,并可落到新内容入口和分类节点。旧单词模板的 `vocabulary_units_示例数据` / `vocabulary_示例数据`、知识手册的书籍/章节/小节/知识点嵌套结构都由后端规范化。Excel/CSV、分数线/视频导入会继续复用同一套 `content_import_jobs` 管线。
## 下一步 ## 下一步

View File

@@ -20,8 +20,8 @@
| 首页/学生看板 | `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` | 已覆盖 | 继续补题量限制、断点续练、更多题型渲染 | | 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 免费额度/SVIP 校验已由后端强制;继续补断点续练、更多题型渲染 |
| 随机刷题 | `pages/Quiz.tsx` | 已覆盖 | 已有 blueprint/session 快照,前端需按 mode 调用 | | 随机刷题 | `pages/Quiz.tsx` | 已覆盖 | 已有 blueprint/session 快照和访问控制,前端需按 mode 调用 |
| 全真模拟 | `components/AdminMockexam``MockExamConfigModal.tsx` | 部分覆盖 | 后端有 blueprint 基础;缺完整交卷报告、排名、复盘 | | 全真模拟 | `components/AdminMockexam``MockExamConfigModal.tsx` | 部分覆盖 | 后端有 blueprint 基础;缺完整交卷报告、排名、复盘 |
| 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 后续补错题复习计划 | | 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 后续补错题复习计划 |
| 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 | | 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 |
@@ -49,7 +49,7 @@
| 商户收款配置 | 部分覆盖 | 配置 API 有;缺真实支付 provider 和验签 | | 商户收款配置 | 部分覆盖 | 配置 API 有;缺真实支付 provider 和验签 |
| 登录配置 | 部分覆盖 | 配置 API 有;缺真实短信/OAuth provider 实现 | | 登录配置 | 部分覆盖 | 配置 API 有;缺真实短信/OAuth provider 实现 |
| Banner/公告/FAQ/活动 | 已覆盖 | 前端运营后台可以接 | | Banner/公告/FAQ/活动 | 已覆盖 | 前端运营后台可以接 |
| SVIP 套餐 | 覆盖 | 后续补地区/分类/专业增项限制规则 | | SVIP 套餐 | 部分覆盖 | 地区/科目/题库范围校验已接入练习/资料/视频;后续补分类/专业增项购买和套餐规则 UI |
| 优惠券 | 部分覆盖 | 后台配置有;前台兑换、下单抵扣待补 | | 优惠券 | 部分覆盖 | 后台配置有;前台兑换、下单抵扣待补 |
| 激活码 | 已覆盖 | 批次、生成、兑换主链路已有 | | 激活码 | 已覆盖 | 批次、生成、兑换主链路已有 |
| 勋章管理 | 部分覆盖 | 表结构有 badges/user_badges缺后台和学生端 API | | 勋章管理 | 部分覆盖 | 表结构有 badges/user_badges缺后台和学生端 API |

View File

@@ -14,6 +14,7 @@
- 销售/代理/CRM 增长链路邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。 - 销售/代理/CRM 增长链路邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。 - 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。 - 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
- 练习访问控制:`practice_daily_usage/practice_access_events` 支持免费每日额度、SVIP 范围校验、SVIP-only 内容拦截和答题 session 快照保护。
- 内容导入:题目、单词、知识手册 JSON 预览、校验、导入、幂等、审计。 - 内容导入:题目、单词、知识手册 JSON 预览、校验、导入、幂等、审计。
- 本地验证:`npm run check:refactor` 已通过。 - 本地验证:`npm run check:refactor` 已通过。
@@ -76,7 +77,8 @@
- 单题视频和通用知识视频混合推荐。 - 单题视频和通用知识视频混合推荐。
6. 学习统计 6. 学习统计
- 练习历史、正确率趋势、题型分布、错题复习计划 - 已完成免费额度和练习访问事件基础
- 继续补练习历史、正确率趋势、题型分布、错题复习计划。
- 单词复习算法、每日计划、排行榜。 - 单词复习算法、每日计划、排行榜。
- 模考交卷、评分报告、错题解析汇总。 - 模考交卷、评分报告、错题解析汇总。

View File

@@ -152,6 +152,51 @@ tenant:<tenantId>:theme
| 个人中心 | `GET/PATCH /api/profile/me` | | 个人中心 | `GET/PATCH /api/profile/me` |
| 销售分享 | `/api/referral/resolve``track-event``bind` | | 销售分享 | `/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。 题目视频分为 `free``svip``video_quota` 三种访问模式。列表接口只用于展示标题、封面、时长、访问模式和试看秒数;除免费公开视频外,列表和搜索接口不会返回可播放 URL。

View File

@@ -28,6 +28,8 @@ const ids = {
practiceBlueprintRandom: '00000000-0000-0000-0000-000000000617', practiceBlueprintRandom: '00000000-0000-0000-0000-000000000617',
practiceBlueprintMock: '00000000-0000-0000-0000-000000000618', practiceBlueprintMock: '00000000-0000-0000-0000-000000000618',
question: '00000000-0000-0000-0000-000000000401', question: '00000000-0000-0000-0000-000000000401',
questionTwo: '00000000-0000-0000-0000-000000000403',
questionThree: '00000000-0000-0000-0000-000000000405',
vocabularyUnit: '00000000-0000-0000-0000-000000000811', vocabularyUnit: '00000000-0000-0000-0000-000000000811',
vocabularyWord: '00000000-0000-0000-0000-000000000812', vocabularyWord: '00000000-0000-0000-0000-000000000812',
video: '00000000-0000-0000-0000-000000000821', 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'); assert.ok(questionsByNode.items?.some(item => item.id === ids.question), 'catalog questions should filter by contentNodeId');
const session = await request('/api/learning/practice-sessions', { const freeLogin = await loginBySms('13800000008');
method: 'POST', const freeSession = await request('/api/learning/practice-sessions', {
body: { userId: USER_ID, mode: 'chapter', targetType: 'category', targetId: ids.question }, userId: false,
}); headers: { authorization: `Bearer ${freeLogin.session.token}` },
assert.ok(session.item?.id, 'practice session should be created');
const sequentialSession = await request('/api/learning/practice-sessions', {
method: 'POST', method: 'POST',
body: { body: {
userId: USER_ID,
mode: 'sequential', mode: 'sequential',
collectionId: ids.questionCollection, collectionId: ids.questionCollection,
questionLimit: 5, questionLimit: 5,
}, },
}); });
assert.equal(sequentialSession.item?.collectionId, ids.questionCollection, 'collection session should bind collection'); assert.equal(freeSession.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?.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', method: 'POST',
body: { 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', mode: 'random',
contentNodeId: ids.contentNodeProfessional, contentNodeId: ids.contentNodeProfessional,
questionLimit: 5, questionLimit: 5,
@@ -596,9 +650,10 @@ async function testCatalogAndLearning() {
assert.ok(nodeSession.item?.questionIds?.includes(ids.question), 'node session should include descendant questions'); assert.ok(nodeSession.item?.questionIds?.includes(ids.question), 'node session should include descendant questions');
const mockSession = await request('/api/learning/practice-sessions', { const mockSession = await request('/api/learning/practice-sessions', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST', method: 'POST',
body: { body: {
userId: USER_ID, userId: TENANT_ADMIN_USER_ID,
blueprintId: ids.practiceBlueprintMock, blueprintId: ids.practiceBlueprintMock,
}, },
}); });
@@ -608,18 +663,11 @@ async function testCatalogAndLearning() {
assert.equal(Number(mockSession.item?.totalScore), 100, 'mock session should inherit total score'); 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'); assert.ok(mockSession.item?.questionIds?.includes(ids.question), 'mock session should snapshot assembled questions');
const answer = await request('/api/learning/answers', { const wrong = await request('/api/learning/wrong-questions', {
method: 'POST', userId: false,
body: { headers: { authorization: `Bearer ${freeLogin.session.token}` },
userId: USER_ID, query: { status: 'all' },
questionId: ids.question,
selectedOptions: ['0'],
practiceSessionId: session.item.id,
},
}); });
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'); assert.ok(wrong.items?.some(item => item.questionId === ids.question), 'wrong book should include smoke question');
} }

View File

@@ -25,6 +25,10 @@ const ids = {
questionBank: '00000000-0000-0000-0000-000000000400', questionBank: '00000000-0000-0000-0000-000000000400',
question: '00000000-0000-0000-0000-000000000401', question: '00000000-0000-0000-0000-000000000401',
questionVersion: '00000000-0000-0000-0000-000000000402', 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', plan: '00000000-0000-0000-0000-000000000201',
order: '00000000-0000-0000-0000-000000000701', order: '00000000-0000-0000-0000-000000000701',
payment: '00000000-0000-0000-0000-000000000702', payment: '00000000-0000-0000-0000-000000000702',
@@ -73,6 +77,97 @@ async function main() {
[tenantId, ids.user, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser], [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( await client.query(
` `
delete from public.referral_qrcodes 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, id, tenant_id, region_id, entry_id, node_id, subject_id, category_id,
question_bank_id, legacy_id, name, collection_type, source_type, question_bank_id, legacy_id, name, collection_type, source_type,
filters, question_count, total_score, duration_minutes, status, 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 ( values (
$1, $2, $3, $4, $5, $6, $7, $1, $2, $3, $4, $5, $6, $7,
$8, 'smoke-collection', '烟测学院专业课题目列表', 'manual', 'manual_questions', $8, 'smoke-collection', '烟测学院专业课题目列表', 'manual', 'manual_questions',
'{"tabs":["all","paper","chapter","type"]}'::jsonb, 0, 100, 120, 'active', '{"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) on conflict (id)
do update set node_id = excluded.node_id, do update set node_id = excluded.node_id,
@@ -348,6 +444,7 @@ async function main() {
total_score = excluded.total_score, total_score = excluded.total_score,
duration_minutes = excluded.duration_minutes, duration_minutes = excluded.duration_minutes,
status = 'active', status = 'active',
access_rules = excluded.access_rules,
updated_at = now() updated_at = now()
`, `,
[ [
@@ -370,11 +467,22 @@ async function main() {
entry_id, content_node_id, primary_collection_id, entry_id, content_node_id, primary_collection_id,
legacy_id, type, type_label, difficulty, status, exam_markers legacy_id, type, type_label, difficulty, status, exam_markers
) )
values ( values
$1, $2, $3, $4, $5, $6, $7, $8, (
'smoke-question', 'choice', '单选题', 1, 'published', $1, $2, $5, $6, $7, $8, $9, $10,
'{"examTrack":"professional","school":"烟测学院"}'::jsonb '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) on conflict (id)
do update set question_bank_id = excluded.question_bank_id, do update set question_bank_id = excluded.question_bank_id,
subject_id = excluded.subject_id, subject_id = excluded.subject_id,
@@ -388,6 +496,8 @@ async function main() {
[ [
ids.question, ids.question,
tenantId, tenantId,
ids.questionTwo,
ids.questionThree,
ids.questionBank, ids.questionBank,
ids.subject, ids.subject,
ids.category, ids.category,
@@ -402,14 +512,17 @@ async function main() {
insert into public.question_collection_items ( insert into public.question_collection_items (
tenant_id, collection_id, question_id, section_key, sort_order, score, required, metadata 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) on conflict (tenant_id, collection_id, question_id)
do update set section_key = excluded.section_key, do update set section_key = excluded.section_key,
sort_order = excluded.sort_order, sort_order = excluded.sort_order,
score = excluded.score, score = excluded.score,
updated_at = now() updated_at = now()
`, `,
[tenantId, ids.questionCollection, ids.question], [tenantId, ids.questionCollection, ids.question, ids.questionTwo, ids.questionThree],
); );
await client.query( await client.query(
@@ -487,11 +600,22 @@ async function main() {
id, tenant_id, question_id, version_no, content, options, id, tenant_id, question_id, version_no, content, options,
correct_option_index, correct_option_indices, answer_text, explanation correct_option_index, correct_option_indices, answer_text, explanation
) )
values ( values
$1, $2, $3, 1, '1 + 1 = ?', (
'[{"label":"A","text":"1"},{"label":"B","text":"2"},{"label":"C","text":"3"}]'::jsonb, $1, $2, $4, 1, '1 + 1 = ?',
1, '[1]'::jsonb, '2', '基础加法。' '[{"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) on conflict (question_id, version_no)
do update set content = excluded.content, do update set content = excluded.content,
options = excluded.options, options = excluded.options,
@@ -500,16 +624,31 @@ async function main() {
answer_text = excluded.answer_text, answer_text = excluded.answer_text,
explanation = excluded.explanation explanation = excluded.explanation
`, `,
[ids.questionVersion, tenantId, ids.question], [ids.questionVersion, tenantId, ids.questionTwoVersion, ids.question, ids.questionTwo, ids.questionThreeVersion, ids.questionThree],
); );
await client.query( await client.query(
` `
update public.questions update public.questions q
set current_version_id = $3, has_video_explanation = true, updated_at = now() set current_version_id = v.version_id,
where tenant_id = $1 and id = $2 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( await client.query(

View File

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