forked from wangziqi/gongxue-base
feat: enforce practice access controls
This commit is contained in:
@@ -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
|
||||
|
||||
564
apps/api/src/features/learning/access.ts
Normal file
564
apps/api/src/features/learning/access.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 基础,缺完整交卷、评分报告、错题解析汇总 |
|
||||
|
||||
## 背单词、知识手册、分数线、视频
|
||||
|
||||
@@ -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` 管线。
|
||||
|
||||
## 下一步
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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. 学习统计
|
||||
- 练习历史、正确率趋势、题型分布、错题复习计划。
|
||||
- 已完成免费额度和练习访问事件基础。
|
||||
- 继续补练习历史、正确率趋势、题型分布、错题复习计划。
|
||||
- 单词复习算法、每日计划、排行榜。
|
||||
- 模考交卷、评分报告、错题解析汇总。
|
||||
|
||||
|
||||
@@ -152,6 +152,51 @@ tenant:<tenantId>: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。
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
95
supabase/migrations/202606210011_practice_access_control.sql
Normal file
95
supabase/migrations/202606210011_practice_access_control.sql
Normal 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 $$;
|
||||
Reference in New Issue
Block a user