From 7c41bf525f3847363788f81a8be3ab14a423e42c Mon Sep 17 00:00:00 2001 From: Codex Date: Mon, 29 Jun 2026 03:07:57 +0800 Subject: [PATCH] feat: add public question bank adoption --- README.md | 2 +- apps/api/src/features/platform-admin/index.ts | 6 + .../api/src/features/platform-admin/routes.ts | 224 ++++++ apps/api/src/features/tenant-content/index.ts | 6 + .../features/tenant-content/public-banks.ts | 646 ++++++++++++++++++ docs/refactor/api-structure.md | 1 + docs/refactor/backend-capability-status.md | 4 + docs/refactor/backend-handoff-roadmap.md | 6 +- docs/refactor/implementation-status.md | 4 +- docs/refactor/legacy-feature-gap-matrix.md | 10 +- docs/refactor/next-development-todo.md | 9 +- docs/refactor/taro-frontend-integration.md | 54 ++ scripts/api-integration-test.js | 113 +++ scripts/smoke-seed.js | 134 +++- ...6290007_public_question_bank_adoptions.sql | 121 ++++ 15 files changed, 1324 insertions(+), 16 deletions(-) create mode 100644 apps/api/src/features/tenant-content/public-banks.ts create mode 100644 supabase/migrations/202606290007_public_question_bank_adoptions.sql diff --git a/README.md b/README.md index 724a5da0..f7eb7d43 100644 --- a/README.md +++ b/README.md @@ -178,4 +178,4 @@ npm run check:refactor 2. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。 3. 对象存储上传后校验、PDF 预览、防盗链和视频水印。 4. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。 -5. 微信网页/QQ 登录、退款对账、支付补偿、CRM worker、公共题库授权、租户采纳、积分活动深化,以及排行榜防刷/预聚合。 +5. 微信网页/QQ 登录、退款对账、支付补偿、CRM worker、公共题库版本同步 worker、积分活动深化,以及排行榜防刷/预聚合。 diff --git a/apps/api/src/features/platform-admin/index.ts b/apps/api/src/features/platform-admin/index.ts index 9c926fc5..b1003aa3 100644 --- a/apps/api/src/features/platform-admin/index.ts +++ b/apps/api/src/features/platform-admin/index.ts @@ -7,18 +7,24 @@ import { createTenantRoute, platformOverviewRoute, platformPlansRoute, + platformQuestionBanksRoute, + questionBankGrantsRoute, recordUsageRoute, tenantDetailRoute, tenantInvoicesRoute, tenantsRoute, tenantUsageRoute, updateTenantStatusRoute, + upsertQuestionBankGrantRoute, upsertBillingProfileRoute, } from './routes.js'; export const platformAdminRoutes: RouteDefinition[] = [ ['GET', '/api/platform-admin/overview', platformOverviewRoute], ['GET', '/api/platform-admin/plans', platformPlansRoute], + ['GET', '/api/platform-admin/question-banks', platformQuestionBanksRoute], + ['GET', '/api/platform-admin/question-bank-grants', questionBankGrantsRoute], + ['PUT', '/api/platform-admin/question-bank-grants', upsertQuestionBankGrantRoute], ['GET', '/api/platform-admin/tenants', tenantsRoute], ['POST', '/api/platform-admin/tenants', createTenantRoute], ['GET', '/api/platform-admin/tenants/detail', tenantDetailRoute], diff --git a/apps/api/src/features/platform-admin/routes.ts b/apps/api/src/features/platform-admin/routes.ts index a949c235..29f73ca1 100644 --- a/apps/api/src/features/platform-admin/routes.ts +++ b/apps/api/src/features/platform-admin/routes.ts @@ -1,6 +1,7 @@ import { HttpError, type RequestContext } from '../../core/http.js'; import { intParam, + optionalStringArray, optionalString, readJsonBody, requiredString, @@ -32,6 +33,26 @@ function toDateText(value: unknown) { return String(value).slice(0, 10); } +function optionalUuidArray(body: Record, key: string) { + return optionalStringArray(body, key).filter(Boolean); +} + +function grantScopeFrom(value: string) { + const scope = value || 'plans'; + if (!['all_active_tenants', 'plans', 'tenants', 'mixed'].includes(scope)) { + throw new HttpError(400, 'grantScope is invalid', 'INVALID_GRANT_SCOPE'); + } + return scope; +} + +function grantStatusFrom(value: string) { + const status = value || 'active'; + if (!['active', 'disabled', 'expired'].includes(status)) { + throw new HttpError(400, 'status is invalid', 'INVALID_STATUS'); + } + return status; +} + export async function platformOverviewRoute(ctx: RequestContext) { await requirePlatformAdmin(ctx); @@ -129,6 +150,209 @@ export async function platformPlansRoute(ctx: RequestContext) { return { items }; } +export async function platformQuestionBanksRoute(ctx: RequestContext) { + await requirePlatformAdmin(ctx); + + const q = listQuery(ctx, 'q'); + const regionId = listQuery(ctx, 'regionId'); + const status = listQuery(ctx, 'status') || 'active'; + const includeTenantBanks = listQuery(ctx, 'includeTenantBanks') === 'true'; + const limit = intParam(ctx, 'limit', 50, 200); + + const items = await query( + ` + select qb.id, qb.tenant_id as "tenantId", t.slug::text as "tenantSlug", t.name as "tenantName", + qb.region_id as "regionId", r.name as "regionName", qb.name, + qb.source_scope as "sourceScope", qb.status, qb.metadata, + coalesce(qs.question_count, 0)::integer as "questionCount", + qb.created_at as "createdAt", qb.updated_at as "updatedAt" + from public.question_banks qb + join public.tenants t on t.id = qb.tenant_id + left join public.regions r on r.id = qb.region_id and r.tenant_id = qb.tenant_id + left join lateral ( + select count(*)::integer as question_count + from public.questions q + where q.tenant_id = qb.tenant_id + and q.question_bank_id = qb.id + and q.status = 'published' + ) qs on true + where ($1::boolean = true or qb.source_scope = 'platform') + and ($2::text = '' or qb.status = $2) + and ($3::uuid is null or qb.region_id = $3::uuid) + and ( + $4::text = '' + or qb.name ilike '%' || $4 || '%' + or coalesce(r.name, '') ilike '%' || $4 || '%' + ) + order by qb.source_scope asc, r.sort_order asc nulls last, qb.created_at desc + limit $5 + `, + [includeTenantBanks, status, regionId || null, q, limit], + ); + + return { items }; +} + +export async function questionBankGrantsRoute(ctx: RequestContext) { + await requirePlatformAdmin(ctx); + + const questionBankId = listQuery(ctx, 'questionBankId'); + const status = listQuery(ctx, 'status'); + const limit = intParam(ctx, 'limit', 100, 500); + + const items = await query( + ` + select g.id, g.source_question_bank_id as "sourceQuestionBankId", + qb.name as "sourceQuestionBankName", qb.region_id as "sourceRegionId", + r.name as "sourceRegionName", g.grant_scope as "grantScope", + g.allowed_plan_codes as "allowedPlanCodes", + g.allowed_tenant_ids as "allowedTenantIds", + g.allowed_region_ids as "allowedRegionIds", + g.allowed_subject_ids as "allowedSubjectIds", + g.status, g.starts_at as "startsAt", g.expires_at as "expiresAt", + g.metadata, g.created_at as "createdAt", g.updated_at as "updatedAt" + from public.question_bank_grants g + join public.question_banks qb on qb.id = g.source_question_bank_id + left join public.regions r on r.id = qb.region_id and r.tenant_id = qb.tenant_id + where ($1::uuid is null or g.source_question_bank_id = $1::uuid) + and ($2::text = '' or g.status = $2) + order by g.updated_at desc, g.created_at desc + limit $3 + `, + [questionBankId || null, status, limit], + ); + + return { items }; +} + +export async function upsertQuestionBankGrantRoute(ctx: RequestContext) { + await requirePlatformAdmin(ctx); + + const body = await readJsonBody(ctx); + const questionBankId = requiredString(body, 'sourceQuestionBankId'); + const grantScope = grantScopeFrom(optionalString(body, 'grantScope')); + const allowedPlanCodes = optionalStringArray(body, 'allowedPlanCodes'); + const allowedTenantIds = optionalUuidArray(body, 'allowedTenantIds'); + const allowedRegionIds = optionalUuidArray(body, 'allowedRegionIds'); + const allowedSubjectIds = optionalUuidArray(body, 'allowedSubjectIds'); + const status = grantStatusFrom(optionalString(body, 'status')); + const startsAt = optionalString(body, 'startsAt') || null; + const expiresAt = optionalString(body, 'expiresAt') || null; + + if (grantScope === 'plans' && allowedPlanCodes.length === 0) { + throw new HttpError(400, 'allowedPlanCodes is required for plan grants', 'ALLOWED_PLANS_REQUIRED'); + } + if (grantScope === 'tenants' && allowedTenantIds.length === 0) { + throw new HttpError(400, 'allowedTenantIds is required for tenant grants', 'ALLOWED_TENANTS_REQUIRED'); + } + if (grantScope === 'mixed' && allowedPlanCodes.length === 0 && allowedTenantIds.length === 0) { + throw new HttpError(400, 'mixed grants require plans or tenants', 'GRANT_TARGET_REQUIRED'); + } + + const item = await transaction(async client => { + const bankResult = await client.query<{ id: string }>( + ` + select id + from public.question_banks + where id = $1 + and source_scope = 'platform' + limit 1 + `, + [questionBankId], + ); + if (!bankResult.rows[0]) { + throw new HttpError(404, 'Platform question bank not found', 'PLATFORM_QUESTION_BANK_NOT_FOUND'); + } + + if (allowedPlanCodes.length) { + const planCount = await client.query<{ count: string }>( + 'select count(*)::text as count from public.platform_saas_plans where code = any($1::text[])', + [allowedPlanCodes], + ); + if (Number(planCount.rows[0]?.count || 0) !== allowedPlanCodes.length) { + throw new HttpError(400, 'One or more SaaS plans do not exist', 'SAAS_PLAN_NOT_FOUND'); + } + } + + if (allowedTenantIds.length) { + const tenantCount = await client.query<{ count: string }>( + 'select count(*)::text as count from public.tenants where id = any($1::uuid[])', + [allowedTenantIds], + ); + if (Number(tenantCount.rows[0]?.count || 0) !== allowedTenantIds.length) { + throw new HttpError(400, 'One or more tenants do not exist', 'TENANT_NOT_FOUND'); + } + } + + const result = await client.query( + ` + insert into public.question_bank_grants ( + id, source_question_bank_id, grant_scope, allowed_plan_codes, + allowed_tenant_ids, allowed_region_ids, allowed_subject_ids, + status, starts_at, expires_at, metadata + ) + values ( + coalesce($1::uuid, gen_random_uuid()), $2, $3, $4::text[], + $5::uuid[], $6::uuid[], $7::uuid[], + $8, $9::timestamptz, $10::timestamptz, $11::jsonb + ) + on conflict (id) + do update set source_question_bank_id = excluded.source_question_bank_id, + grant_scope = excluded.grant_scope, + allowed_plan_codes = excluded.allowed_plan_codes, + allowed_tenant_ids = excluded.allowed_tenant_ids, + allowed_region_ids = excluded.allowed_region_ids, + allowed_subject_ids = excluded.allowed_subject_ids, + status = excluded.status, + starts_at = excluded.starts_at, + expires_at = excluded.expires_at, + metadata = excluded.metadata, + updated_at = now() + returning id, source_question_bank_id as "sourceQuestionBankId", + grant_scope as "grantScope", allowed_plan_codes as "allowedPlanCodes", + allowed_tenant_ids as "allowedTenantIds", allowed_region_ids as "allowedRegionIds", + allowed_subject_ids as "allowedSubjectIds", status, + starts_at as "startsAt", expires_at as "expiresAt", + metadata, created_at as "createdAt", updated_at as "updatedAt" + `, + [ + optionalString(body, 'id') || null, + questionBankId, + grantScope, + allowedPlanCodes, + allowedTenantIds, + allowedRegionIds, + allowedSubjectIds, + status, + startsAt, + expiresAt, + jsonBodyValue(body.metadata), + ], + ); + + await client.query( + ` + insert into public.audit_logs (tenant_id, action, target_type, target_id, details) + values (null, 'platform.question_bank.grant_upserted', 'question_bank_grant', $1, $2::jsonb) + `, + [ + result.rows[0].id, + JSON.stringify({ + sourceQuestionBankId: questionBankId, + grantScope, + allowedPlanCodes, + allowedTenantIds, + status, + }), + ], + ); + + return result.rows[0]; + }); + + return { item }; +} + export async function tenantsRoute(ctx: RequestContext) { await requirePlatformAdmin(ctx); diff --git a/apps/api/src/features/tenant-content/index.ts b/apps/api/src/features/tenant-content/index.ts index 4e5e971f..289df91a 100644 --- a/apps/api/src/features/tenant-content/index.ts +++ b/apps/api/src/features/tenant-content/index.ts @@ -26,6 +26,10 @@ import { upsertPracticeBlueprintRoute, upsertQuestionCollectionRoute, } from './navigation.js'; +import { + adoptPublicQuestionBankRoute, + publicQuestionBanksRoute, +} from './public-banks.js'; import { bindQuestionVideoRoute, createQuestionRoute, @@ -54,6 +58,8 @@ import { export const tenantContentRoutes: RouteDefinition[] = [ ['GET', '/api/tenant-content/content-entries', contentEntriesAdminRoute], + ['GET', '/api/tenant-content/public-question-banks', publicQuestionBanksRoute], + ['POST', '/api/tenant-content/public-question-banks/adopt', adoptPublicQuestionBankRoute], ['PUT', '/api/tenant-content/content-entries', upsertContentEntryRoute], ['GET', '/api/tenant-content/content-nodes', contentNodesAdminRoute], ['PUT', '/api/tenant-content/content-nodes', upsertContentNodeRoute], diff --git a/apps/api/src/features/tenant-content/public-banks.ts b/apps/api/src/features/tenant-content/public-banks.ts new file mode 100644 index 00000000..aaabb2a7 --- /dev/null +++ b/apps/api/src/features/tenant-content/public-banks.ts @@ -0,0 +1,646 @@ +import type pg from 'pg'; +import { HttpError, type RequestContext } from '../../core/http.js'; +import { intParam, optionalString, readJsonBody, requiredString, stringParam } from '../../core/request.js'; +import { query, transaction } from '../../core/db.js'; +import { requireTenantContentEditor, type TenantContentAuth } from './auth.js'; +import { boolValue, intValue, jsonObjectValue, nullableString } from './utils.js'; + +interface EligibleBankRow { + grantId: string; + sourceQuestionBankId: string; + sourceQuestionBankName: string; + sourceTenantId: string; + sourceRegionId: string | null; + sourceRegionName: string | null; + grantScope: string; + allowedPlanCodes: string[]; + questionCount: number; + adoptedId: string | null; + adoptionStatus: string | null; + targetQuestionBankId: string | null; + targetEntryId: string | null; + targetCollectionId: string | null; +} + +function slugFromName(name: string) { + const ascii = name + .normalize('NFKD') + .replace(/[^\w\s-]/g, '') + .trim() + .toLowerCase() + .replace(/[\s_]+/g, '-') + .replace(/-+/g, '-') + .slice(0, 48); + return ascii || 'public-bank'; +} + +async function ensureTenantRegion(client: pg.PoolClient, targetTenantId: string, sourceTenantId: string, sourceRegionId: string | null) { + if (!sourceRegionId) return null; + const existing = await client.query<{ id: string }>( + ` + select id + from public.regions + where tenant_id = $1 + and config->'sourceAdoption'->>'sourceTenantId' = $2 + and config->'sourceAdoption'->>'sourceRegionId' = $3 + limit 1 + `, + [targetTenantId, sourceTenantId, sourceRegionId], + ); + if (existing.rows[0]) return existing.rows[0].id; + + const source = await client.query<{ + name: string; + code: string | null; + short_name: string | null; + full_name: string | null; + icon: string | null; + pinyin: string | null; + sort_order: number; + is_hot: boolean; + config: Record; + }>( + ` + select name, code, short_name, full_name, icon, pinyin, sort_order, is_hot, config + from public.regions + where tenant_id = $1 and id = $2 + limit 1 + `, + [sourceTenantId, sourceRegionId], + ); + const row = source.rows[0]; + if (!row) return null; + + const inserted = await client.query<{ id: string }>( + ` + insert into public.regions ( + tenant_id, name, code, short_name, full_name, icon, pinyin, + sort_order, is_hot, is_active, config + ) + values ( + $1, $2, $3, $4, $5, $6, $7, + $8, $9, true, $10::jsonb + ) + returning id + `, + [ + targetTenantId, + row.name, + row.code, + row.short_name, + row.full_name, + row.icon, + row.pinyin, + row.sort_order, + row.is_hot, + JSON.stringify({ + ...(row.config || {}), + sourceAdoption: { + source: 'public_question_bank_adoption', + sourceTenantId, + sourceRegionId, + }, + }), + ], + ); + + return inserted.rows[0]?.id || null; +} + +async function loadEligibleGrant( + client: pg.PoolClient, + tenantId: string, + grantId: string, +) { + const result = await client.query( + ` + with active_subscriptions as ( + select plan_code + from public.tenant_subscriptions + where tenant_id = $1 + and status in ('trial', 'active') + and (expires_at is null or expires_at > now()) + ) + select g.id as "grantId", + qb.id as "sourceQuestionBankId", + qb.name as "sourceQuestionBankName", + qb.tenant_id as "sourceTenantId", + qb.region_id as "sourceRegionId", + r.name as "sourceRegionName", + g.grant_scope as "grantScope", + g.allowed_plan_codes as "allowedPlanCodes", + coalesce(qs.question_count, 0)::integer as "questionCount", + a.id as "adoptedId", + a.status as "adoptionStatus", + a.target_question_bank_id as "targetQuestionBankId", + a.target_entry_id as "targetEntryId", + a.target_collection_id as "targetCollectionId" + from public.question_bank_grants g + join public.question_banks qb on qb.id = g.source_question_bank_id + left join public.regions r on r.id = qb.region_id and r.tenant_id = qb.tenant_id + left join lateral ( + select count(*)::integer as question_count + from public.questions q + where q.tenant_id = qb.tenant_id + and q.question_bank_id = qb.id + and q.status = 'published' + ) qs on true + left join public.tenant_question_bank_adoptions a + on a.tenant_id = $1 + and a.source_question_bank_id = qb.id + where g.id = $2 + and g.status = 'active' + and (g.starts_at is null or g.starts_at <= now()) + and (g.expires_at is null or g.expires_at > now()) + and qb.source_scope = 'platform' + and qb.status = 'active' + and ( + ( + g.grant_scope = 'all_active_tenants' + and exists (select 1 from active_subscriptions) + ) + or ( + g.grant_scope in ('plans', 'mixed') + and exists ( + select 1 + from active_subscriptions s + where s.plan_code = any(g.allowed_plan_codes) + ) + ) + or ( + g.grant_scope in ('tenants', 'mixed') + and $1 = any(g.allowed_tenant_ids) + ) + ) + limit 1 + `, + [tenantId, grantId], + ); + return result.rows[0] || null; +} + +async function copyQuestionsSnapshot(client: pg.PoolClient, input: { + auth: TenantContentAuth; + sourceTenantId: string; + sourceQuestionBankId: string; + targetQuestionBankId: string; + targetEntryId: string; + targetCollectionId: string; + copyLimit: number; +}) { + const sourceQuestions = await client.query<{ + id: string; + subject_id: string | null; + category_id: string | null; + type: string; + type_label: string | null; + difficulty: number | null; + tags: unknown[]; + media_url: string | null; + has_video_explanation: boolean; + current_version_id: string | null; + content: string | null; + options: unknown[]; + correct_option_index: number | null; + correct_option_indices: unknown[]; + answer_text: string | null; + explanation: string | null; + sub_questions: unknown[]; + code_lang: string | null; + code_template: string | null; + source_hash: string | null; + }>( + ` + select q.id, q.subject_id, q.category_id, q.type, q.type_label, + q.difficulty, q.tags, q.media_url, q.has_video_explanation, + q.current_version_id, + v.content, v.options, v.correct_option_index, v.correct_option_indices, + v.answer_text, v.explanation, v.sub_questions, v.code_lang, + v.code_template, v.source_hash + from public.questions q + left join public.question_versions v on v.id = q.current_version_id + where q.tenant_id = $1 + and q.question_bank_id = $2 + and q.status = 'published' + order by q.created_at asc + limit $3 + `, + [input.sourceTenantId, input.sourceQuestionBankId, input.copyLimit], + ); + + let order = 0; + for (const source of sourceQuestions.rows) { + const legacyId = `public:${input.sourceTenantId}:${source.id}`; + const questionResult = await client.query<{ id: string }>( + ` + insert into public.questions ( + tenant_id, question_bank_id, entry_id, primary_collection_id, + legacy_id, type, type_label, difficulty, tags, media_url, + has_video_explanation, status, exam_markers + ) + values ( + $1, $2, $3, $4, + $5, $6, $7, $8, $9::jsonb, $10, + $11, 'published', $12::jsonb + ) + on conflict (tenant_id, legacy_id) + do update set question_bank_id = excluded.question_bank_id, + entry_id = excluded.entry_id, + primary_collection_id = excluded.primary_collection_id, + type = excluded.type, + type_label = excluded.type_label, + difficulty = excluded.difficulty, + tags = excluded.tags, + media_url = excluded.media_url, + has_video_explanation = excluded.has_video_explanation, + status = 'published', + exam_markers = excluded.exam_markers, + updated_at = now() + returning id + `, + [ + input.auth.tenantId, + input.targetQuestionBankId, + input.targetEntryId, + input.targetCollectionId, + legacyId, + source.type, + source.type_label, + source.difficulty, + JSON.stringify(source.tags || []), + source.media_url, + source.has_video_explanation, + JSON.stringify({ + sourceTenantId: input.sourceTenantId, + sourceQuestionBankId: input.sourceQuestionBankId, + sourceQuestionId: source.id, + }), + ], + ); + const questionId = questionResult.rows[0].id; + + const versionResult = await client.query<{ id: string }>( + ` + insert into public.question_versions ( + tenant_id, question_id, version_no, content, options, + correct_option_index, correct_option_indices, answer_text, + explanation, sub_questions, code_lang, code_template, + source_hash, created_by + ) + values ( + $1, $2, 1, $3, $4::jsonb, + $5, $6::jsonb, $7, + $8, $9::jsonb, $10, $11, + $12, $13 + ) + on conflict (question_id, version_no) + do update set content = excluded.content, + options = excluded.options, + correct_option_index = excluded.correct_option_index, + correct_option_indices = excluded.correct_option_indices, + answer_text = excluded.answer_text, + explanation = excluded.explanation, + sub_questions = excluded.sub_questions, + code_lang = excluded.code_lang, + code_template = excluded.code_template, + source_hash = excluded.source_hash + returning id + `, + [ + input.auth.tenantId, + questionId, + source.content, + JSON.stringify(source.options || []), + source.correct_option_index, + JSON.stringify(source.correct_option_indices || []), + source.answer_text, + source.explanation, + JSON.stringify(source.sub_questions || []), + source.code_lang, + source.code_template, + source.source_hash || `public:${source.id}`, + input.auth.userId, + ], + ); + + await client.query( + 'update public.questions set current_version_id = $3, updated_at = now() where tenant_id = $1 and id = $2', + [input.auth.tenantId, questionId, versionResult.rows[0].id], + ); + + await client.query( + ` + insert into public.question_collection_items ( + tenant_id, collection_id, question_id, section_key, sort_order, score, required, metadata + ) + values ($1, $2, $3, $4, $5, null, true, $6::jsonb) + on conflict (tenant_id, collection_id, question_id) + do update set section_key = excluded.section_key, + sort_order = excluded.sort_order, + metadata = excluded.metadata, + updated_at = now() + `, + [ + input.auth.tenantId, + input.targetCollectionId, + questionId, + source.type, + order, + JSON.stringify({ source: 'public_question_bank_adoption', sourceQuestionId: source.id }), + ], + ); + order += 1; + } + + await client.query( + ` + update public.question_collections + set question_count = $3, + updated_at = now() + where tenant_id = $1 and id = $2 + `, + [input.auth.tenantId, input.targetCollectionId, sourceQuestions.rows.length], + ); + + return sourceQuestions.rows.length; +} + +export async function publicQuestionBanksRoute(ctx: RequestContext) { + const auth = await requireTenantContentEditor(ctx); + const q = stringParam(ctx, 'q'); + const regionId = stringParam(ctx, 'regionId'); + const onlyNotAdopted = stringParam(ctx, 'onlyNotAdopted') === 'true'; + const limit = intParam(ctx, 'limit', 50, 200); + + const items = await query( + ` + with active_subscriptions as ( + select plan_code + from public.tenant_subscriptions + where tenant_id = $1 + and status in ('trial', 'active') + and (expires_at is null or expires_at > now()) + ), + eligible_grants as ( + select g.* + from public.question_bank_grants g + where g.status = 'active' + and (g.starts_at is null or g.starts_at <= now()) + and (g.expires_at is null or g.expires_at > now()) + and ( + ( + g.grant_scope = 'all_active_tenants' + and exists (select 1 from active_subscriptions) + ) + or ( + g.grant_scope in ('plans', 'mixed') + and exists ( + select 1 + from active_subscriptions s + where s.plan_code = any(g.allowed_plan_codes) + ) + ) + or ( + g.grant_scope in ('tenants', 'mixed') + and $1 = any(g.allowed_tenant_ids) + ) + ) + ) + select g.id as "grantId", + qb.id as "sourceQuestionBankId", + qb.name as "sourceQuestionBankName", + qb.tenant_id as "sourceTenantId", + qb.region_id as "sourceRegionId", + r.name as "sourceRegionName", + g.grant_scope as "grantScope", + g.allowed_plan_codes as "allowedPlanCodes", + coalesce(qs.question_count, 0)::integer as "questionCount", + a.id as "adoptedId", + a.status as "adoptionStatus", + a.target_question_bank_id as "targetQuestionBankId", + a.target_entry_id as "targetEntryId", + a.target_collection_id as "targetCollectionId" + from eligible_grants g + join public.question_banks qb on qb.id = g.source_question_bank_id + left join public.regions r on r.id = qb.region_id and r.tenant_id = qb.tenant_id + left join public.tenant_question_bank_adoptions a + on a.tenant_id = $1 + and a.source_question_bank_id = qb.id + left join lateral ( + select count(*)::integer as question_count + from public.questions q + where q.tenant_id = qb.tenant_id + and q.question_bank_id = qb.id + and q.status = 'published' + ) qs on true + where qb.source_scope = 'platform' + and qb.status = 'active' + and ($2::uuid is null or qb.region_id = $2::uuid) + and ($3::text = '' or qb.name ilike '%' || $3 || '%' or coalesce(r.name, '') ilike '%' || $3 || '%') + and ($4::boolean = false or a.id is null) + order by r.sort_order asc nulls last, qb.created_at desc + limit $5 + `, + [auth.tenantId, regionId || null, q, onlyNotAdopted, limit], + ); + + return { items }; +} + +export async function adoptPublicQuestionBankRoute(ctx: RequestContext) { + const auth = await requireTenantContentEditor(ctx); + const body = await readJsonBody(ctx); + const grantId = requiredString(body, 'grantId'); + const copyLimit = Math.max(1, Math.min(intValue(body.copyLimit, 200), 1000)); + const entryNameInput = nullableString(body.entryName); + const collectionNameInput = nullableString(body.collectionName); + const isActive = boolValue(body.isActive, true); + + const item = await transaction(async client => { + const grant = await loadEligibleGrant(client, auth.tenantId, grantId); + if (!grant) { + throw new HttpError(403, 'Question bank grant is not available for this tenant', 'QUESTION_BANK_GRANT_NOT_AVAILABLE'); + } + if (grant.adoptedId && grant.adoptionStatus !== 'archived') { + throw new HttpError(409, 'Question bank has already been adopted by this tenant', 'QUESTION_BANK_ALREADY_ADOPTED'); + } + + const targetRegionId = await ensureTenantRegion(client, auth.tenantId, grant.sourceTenantId, grant.sourceRegionId); + const baseKey = `public-${slugFromName(grant.sourceQuestionBankName)}-${grant.sourceQuestionBankId.slice(0, 8)}`; + const entryName = entryNameInput || `${grant.sourceQuestionBankName}`; + const collectionName = collectionNameInput || `${grant.sourceQuestionBankName}题目`; + + const targetBankResult = await client.query<{ id: string }>( + ` + insert into public.question_banks (tenant_id, region_id, name, source_scope, status, metadata) + values ($1, $2::uuid, $3, 'tenant', 'active', $4::jsonb) + returning id + `, + [ + auth.tenantId, + targetRegionId, + grant.sourceQuestionBankName, + JSON.stringify({ + source: 'public_question_bank_adoption', + sourceTenantId: grant.sourceTenantId, + sourceQuestionBankId: grant.sourceQuestionBankId, + grantId, + }), + ], + ); + const targetQuestionBankId = targetBankResult.rows[0].id; + + const entryResult = await client.query<{ id: string }>( + ` + insert into public.content_entries ( + tenant_id, region_id, entry_key, name, entry_type, + icon, route, description, visibility, access_rules, + layout_config, sort_order, is_active, created_by, updated_by + ) + values ( + $1, $2::uuid, $3, $4, 'question_practice', + $5, '/practice', $6, 'public', '{}'::jsonb, + $7::jsonb, 100, $8, $9, $9 + ) + returning id + `, + [ + auth.tenantId, + targetRegionId, + baseKey, + entryName, + optionalString(body, 'icon') || 'book-open', + `采纳自平台公共题库:${grant.sourceQuestionBankName}`, + jsonObjectValue(body.layoutConfig || { source: 'public_question_bank_adoption', tabs: ['all', 'paper', 'chapter', 'type'] }), + isActive, + auth.userId, + ], + ); + const targetEntryId = entryResult.rows[0].id; + + const collectionResult = await client.query<{ id: string }>( + ` + insert into public.question_collections ( + tenant_id, region_id, entry_id, question_bank_id, name, + collection_type, source_type, filters, question_count, + status, sort_order, access_rules, metadata, created_by, updated_by + ) + values ( + $1, $2::uuid, $3, $4, $5, + 'manual', 'manual_questions', '{}'::jsonb, 0, + 'active', 1, $6::jsonb, $7::jsonb, $8, $8 + ) + returning id + `, + [ + auth.tenantId, + targetRegionId, + targetEntryId, + targetQuestionBankId, + collectionName, + jsonObjectValue(body.accessRules), + JSON.stringify({ + source: 'public_question_bank_adoption', + sourceTenantId: grant.sourceTenantId, + sourceQuestionBankId: grant.sourceQuestionBankId, + grantId, + }), + auth.userId, + ], + ); + const targetCollectionId = collectionResult.rows[0].id; + + const copiedQuestionCount = await copyQuestionsSnapshot(client, { + auth, + sourceTenantId: grant.sourceTenantId, + sourceQuestionBankId: grant.sourceQuestionBankId, + targetQuestionBankId, + targetEntryId, + targetCollectionId, + copyLimit, + }); + + const adoptionResult = await client.query( + ` + insert into public.tenant_question_bank_adoptions ( + tenant_id, source_question_bank_id, grant_id, target_question_bank_id, + target_entry_id, target_collection_id, adoption_mode, status, + sync_status, source_snapshot, copied_question_count, metadata, + created_by, updated_by, last_synced_at + ) + values ( + $1, $2, $3, $4, + $5, $6, 'copied_snapshot', 'active', + 'synced', $7::jsonb, $8, $9::jsonb, + $10, $10, now() + ) + on conflict (tenant_id, source_question_bank_id) + do update set grant_id = excluded.grant_id, + target_question_bank_id = excluded.target_question_bank_id, + target_entry_id = excluded.target_entry_id, + target_collection_id = excluded.target_collection_id, + status = 'active', + sync_status = excluded.sync_status, + source_snapshot = excluded.source_snapshot, + copied_question_count = excluded.copied_question_count, + metadata = excluded.metadata, + updated_by = excluded.updated_by, + last_synced_at = excluded.last_synced_at, + updated_at = now() + returning id, tenant_id as "tenantId", + source_question_bank_id as "sourceQuestionBankId", + grant_id as "grantId", + target_question_bank_id as "targetQuestionBankId", + target_entry_id as "targetEntryId", + target_collection_id as "targetCollectionId", + adoption_mode as "adoptionMode", status, sync_status as "syncStatus", + source_snapshot as "sourceSnapshot", + copied_question_count as "copiedQuestionCount", + metadata, created_at as "createdAt", updated_at as "updatedAt" + `, + [ + auth.tenantId, + grant.sourceQuestionBankId, + grantId, + targetQuestionBankId, + targetEntryId, + targetCollectionId, + JSON.stringify({ + sourceTenantId: grant.sourceTenantId, + sourceQuestionBankId: grant.sourceQuestionBankId, + sourceQuestionBankName: grant.sourceQuestionBankName, + sourceRegionId: grant.sourceRegionId, + sourceRegionName: grant.sourceRegionName, + sourceQuestionCount: grant.questionCount, + }), + copiedQuestionCount, + jsonObjectValue(body.metadata), + auth.userId, + ], + ); + + await client.query( + ` + insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details) + values ($1, $2, 'content.public_question_bank.adopted', 'tenant_question_bank_adoption', $3, $4::jsonb) + `, + [ + auth.tenantId, + auth.userId, + adoptionResult.rows[0].id, + JSON.stringify({ + grantId, + sourceQuestionBankId: grant.sourceQuestionBankId, + targetQuestionBankId, + targetEntryId, + targetCollectionId, + copiedQuestionCount, + }), + ], + ); + + return adoptionResult.rows[0]; + }); + + return { item }; +} diff --git a/docs/refactor/api-structure.md b/docs/refactor/api-structure.md index 2b9f1778..a5827e9f 100644 --- a/docs/refactor/api-structure.md +++ b/docs/refactor/api-structure.md @@ -70,6 +70,7 @@ types.ts 仅本领域使用的类型 - `tenant-admin` 权限由 `tenant_memberships.role` 的默认权限和 `permissions` JSON 覆盖共同决定;后端接口必须校验具体权限点,不能只依赖前端菜单隐藏。 - `referral` 是增长/客资业务域,负责邀请码、扫码事件、首绑保护、销售/代理团队归属和 CRM 入队;真实 CRM webhook 发送应由 worker 处理,API 只负责幂等入队。 - 题库前端入口不再只依赖旧 `module_nodes/subjects/categories`;新业务主模型是 `content_entries/content_nodes/question_collections/practice_blueprints`,用于表达可视化入口、多级分类、考试意向标记、题目列表和顺序/随机/全真模拟规则。 +- 平台公共题库不能被租户前端直接跨租户读取;平台侧通过 `/api/platform-admin/question-bank-grants` 授权,租户侧通过 `/api/tenant-content/public-question-banks/adopt` 采纳为本租户题库、入口、集合和题目快照。后续版本同步必须走 worker 和审计。 - `learning` 创建练习 session 时必须保存 `question_ids` 快照,避免随机刷题和模考过程中题目集合变化导致答题记录无法复盘。 - 排行榜必须由后端按租户、地区、班级和可信用户上下文聚合,前端不能自行扫描答题记录、积分流水或单词进度后排名;后续高流量场景再通过 worker/materialized view 做日榜、周榜和防刷。 - 资料、PDF、视频等对象存储资源必须先进入 `content_assets` 台账,再通过 API/Edge Function 做权限校验和签名 URL 下发;前端不能直接拼 OSS/COS/Supabase Storage 私有地址。 diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index ed582b45..0c005eaa 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -126,6 +126,8 @@ | 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 | | 平台租户/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*` | | 数据看板聚合接口 | 待补齐 | 表基础已有,缺完整 dashboard API | +| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 | +| 租户采纳公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习 | ## 销售、代理、CRM @@ -146,11 +148,13 @@ | --- | --- | --- | | PocketBase schema 分析 | 可联调 | `scripts/import-pocketbase` | | 题目 JSON preview/import | 可联调 | 后端负责规范化、issue、幂等、审计 | +| 公共题库采纳快照 | 可联调 | 平台授权后,租户可采纳公共题库并复制已发布题目快照;已覆盖跨租户、重复采纳和采纳后组卷测试 | | 单词 JSON preview/import | 可联调 | 兼容旧模板 | | 知识手册 JSON preview/import | 可联调 | 支持书籍/章节/小节/知识点归一化 | | Excel/CSV 导入 | 待补齐 | 应复用 `content_import_jobs` 管线 | | 分数线/视频批量导入 | 待补齐 | 应复用同一导入管线 | | 大批量异步导入 | 待补齐 | 需要 `apps/worker` | +| 公共题库版本同步 | 待补齐 | 当前采纳为快照复制;后续需 worker 做增量同步、冲突处理、版本升级通知和租户自改保护 | ## 当前验证 diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 08cd6903..939bfb3f 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -21,9 +21,9 @@ | 模块 | 当前状态 | 已经具备 | 上线前还要补 | | --- | --- | --- | --- | | 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射 | 真实云端 Auth/JWKS 回归、生产 RLS 深测 | -| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量 | 自动计费、平台审计、公共题库披露策略 | +| 平台后台 | 基础完成 | 租户、套餐、订阅、账单、服务费、用量、公共题库授权 | 自动计费、平台审计、公共题库版本同步 | | 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、成员权限、角色模板、菜单/模块/字段权限配置 API | 前端权限 UI、班级/教师/学生范围权限 | -| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜 | 专项策略、公题库采纳/授权、Excel 导入、排行榜防刷/预聚合 | +| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照 | 专项策略、公共题库版本同步、Excel 导入、排行榜防刷/预聚合 | | 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON 导入、排行榜 | Excel 导入、更细复习参数 | | 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON 导入 | 富文本资源、版本管理、附件/PDF 关联 | | 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护 | 批量导入、复杂筛选、AI 择校上下文 | @@ -86,7 +86,7 @@ - 退款、对账、支付补偿任务和异常订单处理。 - XPay 或其它实际支付网关 adapter。 - 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录。 -- 公共题库/地区题库授权,租户按 SaaS 套餐购买地区、科目和题库范围。 +- 公共题库/地区题库版本同步,租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。 - Excel/CSV、分数线、视频批量导入。 - 视频会员播放次数、播放日志、防盗链、水印。 - 数据看板 API:收益、注册趋势、答题次数、收入趋势、题型分布、题目总量、套餐销量、24h 活跃。 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 1e69eb21..ec7af04f 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -24,7 +24,7 @@ | 模块 | 数据模型 | PocketBase 导入 | API | 自动化测试 | 当前状态 | | --- | --- | --- | --- | --- | --- | | 多租户隔离 | 已建 `tenants`、`tenant_domains`、`tenant_branding`、`tenant_settings`、RLS 基础 | 部分支持 | 租户解析、品牌、域名、支付账户、登录 provider、平台建租户已实现 | 核心 API 集成测试含租户隔离断言 | 基础可用,正式 JWT/RLS 权限闭环未完成 | -| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账 | 已支持核心映射,JSON 导入可落到新入口/节点/集合 | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、租户后台题目录入/更新、JSON 预览/导入已实现 | 核心 API 集成测试含导航、组卷、导入断言 | 新题库导航和组卷基础闭环可跑,完整交卷评分报告、Excel 导入、公题库采纳/授权仍需补齐 | +| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账、公共题库授权/采纳表 | 已支持核心映射,JSON 导入可落到新入口/节点/集合 | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、租户后台题目录入/更新、JSON 预览/导入、平台公共题库授权、租户采纳快照已实现 | 核心 API 集成测试含导航、组卷、导入、公共题库授权和采纳后组卷断言 | 新题库导航和组卷基础闭环可跑,公共题库采纳快照可联调;Excel 导入、公共题库全量/增量版本同步仍需补齐 | | 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 | | 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 | | 用户订阅/题库会员/SVIP | 已建 `orders`、`payments`、`entitlements`、`svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、订单详情/状态轮询、手工支付确认权限保护、微信/支付宝支付、激活码预检查/兑换、优惠券抵扣、零元订单自动开通、权益查询已实现 | API 集成测试 | 商城主链路可联调,退款/对账/支付补偿和异常订单处理待补 | @@ -259,7 +259,7 @@ platform-admin: 为了先把旧项目核心业务补齐,再进入支付/短信等商用关键模块,建议按下面顺序继续: 1. 完善内容导入和文件上传:Excel/CSV、分数线、视频导入,接真实 OSS/COS/Supabase Storage 签名,并把 JSON 导入扩展为异步 worker。 -2. 补地区/公共题库披露策略、租户套餐地区限制、主题模板系统。 +2. 补公共题库版本同步 worker、租户套餐地区/科目/题库范围限制、主题模板系统。 3. 补学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。 4. 补视频商用控制:SVIP 权限、签名 URL、防盗链、水印、播放次数扣减。 5. 补 AI 择校推荐报告、排行榜防刷/预聚合、勋章自动发放。 diff --git a/docs/refactor/legacy-feature-gap-matrix.md b/docs/refactor/legacy-feature-gap-matrix.md index f5855bbd..697d18d0 100644 --- a/docs/refactor/legacy-feature-gap-matrix.md +++ b/docs/refactor/legacy-feature-gap-matrix.md @@ -46,7 +46,7 @@ | 销售/代理管理 | 部分覆盖 | referral/team/stats 有;缺分佣比例、结算单、审核、导出 | | 班级/教师管理 | 已覆盖 | 班级、班级成员、教师/班主任/助教/学生范围权限已有;可视化 UI 和更细数据范围组合待补 | | 数据看板 | 部分覆盖 | 表基础有;缺收益、注册、答题、活跃、套餐销量等聚合 API | -| 地区管理 | 部分覆盖 | 地区和内容入口已有;缺按 SaaS 套餐限制地区/题库授权的完整流程 | +| 地区管理 | 部分覆盖 | 地区和内容入口已有;平台公共题库已可按 SaaS 套餐/租户授权并由租户采纳;还缺更完整的全国/单地区套餐 UI 和版本同步策略 | | 品牌配置 | 已覆盖 | 需要前端做预览和主题发布体验 | | 自定义域名 | 已覆盖 | 生产需补 DNS 校验、证书状态、回源校验 | | 主题系统 | 部分覆盖 | 当前 theme JSON 可用;缺三套平台主题模板和素材管理 | @@ -73,10 +73,10 @@ | 功能 | 新后端状态 | 待补齐 | | --- | --- | --- | | 创建/管理租户 | 已覆盖 | 平台后台页面待做 | -| SaaS 套餐 | 已覆盖 | 需要和地区/题库授权策略打通 | +| SaaS 套餐 | 部分覆盖 | 已和公共题库授权打通;后续继续补地区数量、科目范围、存储/学生数等组合套餐限制 | | 年费/服务费账单 | 已覆盖 | 真实支付/开票/催缴流程待补 | | 租户用量记录 | 已覆盖 | 自动采集 worker 待补 | -| 公共题库/地区题库 | 部分覆盖 | question_banks 有 source_scope;缺平台披露策略、SaaS 套餐授权、租户采纳/复制/版本同步 | +| 公共题库/地区题库 | 部分覆盖 | 已有平台公共题库列表、授权、租户可采纳列表、采纳快照复制、采纳后练习组卷;缺全量/增量版本同步、冲突处理和运营 UI | | 跨租户运营看板 | 部分覆盖 | overview 有基础;缺完整 BI 聚合 | | 租户安全审计 | 部分覆盖 | audit logs 有;缺平台级审计报表 | @@ -99,7 +99,7 @@ 2. 账号设置完整流:头像上传、绑定/更换手机号、微信/QQ 账号合并、密码/邮箱能力。 3. 题库导出:PDF/Word/JSON 导出、水印、导出审计和权限控制。 4. 导入扩展:Excel/CSV、分数线、视频批量导入和大批量异步 worker。 -5. 公共题库商业化:平台公共/地区题库披露、租户采纳、套餐授权、版本同步。 +5. 公共题库商业化:平台公共/地区题库授权和租户快照采纳已完成基础闭环;还需版本同步、租户自改冲突处理和运营后台 UI。 6. CRM/销售结算:真实 CRM worker、轮询/定向分配、分佣规则、结算单、审核和导出。 7. 题目反馈增强:处理通知、消息提醒、问题聚合统计和内容修复闭环。 8. 积分活动增强:积分兑换、活动任务、连续签到奖励规则和风控。 @@ -118,7 +118,7 @@ 2. 对象存储 PDF 预览、视频深度防盗链、动态水印。 3. Excel/CSV、分数线、视频批量导入。 4. 数据看板和销售/代理分佣结算。 -5. 公共题库授权、租户采纳和版本同步。 +5. 公共题库版本同步、租户采纳后的更新策略和同步 worker。 ### P2:增强体验 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 9b0f407a..cb0f2a2c 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -21,6 +21,7 @@ - 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;后续补批量 CRM 推送和自动学习督导。 - 旧题库运营缺口已补一批:考试日期/倒计时、题目反馈/纠错处理、每日签到积分和积分流水、学习排行榜已完成接口和集成测试。 - 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。 +- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session。 - 本地验证:`npm run check:refactor` 已通过。 当前更适合进入前端联调前阅读的总览文档: @@ -72,9 +73,9 @@ - 大批量导入异步 worker、重试、导入后校验。 4. 公共题库和租户授权 - - 平台公共题库/地区题库。 - - 按 SaaS 套餐限制地区、科目、题库范围。 - - 租户采纳、复制、授权、版本同步策略。 + - 已完成平台公共题库/地区题库的基础授权、租户采纳和题目快照复制。 + - 继续补按 SaaS 套餐限制地区数量、科目范围、题库范围的更细计费策略。 + - 继续补公共题库版本同步 worker、租户自改冲突处理、同步失败重试和运营后台 UI。 5. 视频会员控制 - 已完成视频 SVIP 权限、播放次数扣减、签名播放和播放日志。 @@ -192,5 +193,5 @@ 2. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。 3. 导出现有 PocketBase 数据,做完整 dry-run 迁移。 4. 开始 `apps/taro`,先接租户解析、首页、题库、背单词、知识手册。 -5. 并行补对象存储、真实登录、退款对账、CRM worker 和公共题库授权。 +5. 并行补对象存储、真实登录、退款对账、CRM worker 和公共题库版本同步 worker。 6. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 88812a1d..0ad26325 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -181,6 +181,7 @@ tenant::theme | 租户教师 | `GET /api/tenant-admin/teachers` | | 租户考试日期 | `GET/PUT /api/tenant-admin/exam-dates` | | 租户反馈处理 | `GET /api/tenant-admin/feedbacks`、`POST /api/tenant-admin/feedbacks/status`、`GET /api/tenant-admin/feedbacks/events` | +| 公共题库采纳 | `GET /api/tenant-content/public-question-banks`、`POST /api/tenant-content/public-question-banks/adopt` | ## 练习访问控制契约 @@ -521,6 +522,59 @@ content_entries - 教师可以为范围内学生创建备注和跟进任务,但是否能禁用学生、批量导入、查看手机号由后端权限和字段权限决定;前端只按返回值渲染。 - H5 自定义域名下要注意缓存隔离,不能把 A 租户主题缓存用到 B 租户。 +## 公共题库采纳对接 + +平台超级管理员后台使用: + +```text +GET /api/platform-admin/question-banks +GET /api/platform-admin/question-bank-grants +PUT /api/platform-admin/question-bank-grants +``` + +授权参数建议: + +```json +{ + "sourceQuestionBankId": "<平台公共题库ID>", + "grantScope": "plans", + "allowedPlanCodes": ["starter_yearly", "pro_yearly"], + "status": "active" +} +``` + +`grantScope` 可选: + +- `plans`:按 SaaS 套餐授权。 +- `tenants`:指定租户授权。 +- `mixed`:套餐和指定租户同时生效。 +- `all_active_tenants`:所有有效订阅租户可见。 + +租户内容后台使用: + +```text +GET /api/tenant-content/public-question-banks +POST /api/tenant-content/public-question-banks/adopt +``` + +采纳请求: + +```json +{ + "grantId": "<授权ID>", + "entryName": "天津专升本公共题库", + "collectionName": "天津专升本公共题目", + "copyLimit": 500 +} +``` + +前端处理规则: + +- 租户只能看到后端判定为已授权的公共题库,不要在前端用套餐码自行过滤。 +- 采纳成功后后端会生成本租户自己的 `questionBankId`、`entryId`、`collectionId` 和题目快照,学生端直接按普通 `/api/catalog/content-entries`、`question-collections`、`practice-sessions` 接入。 +- 重复采纳返回 `QUESTION_BANK_ALREADY_ADOPTED`,前端展示“已采纳”即可。 +- 当前版本是快照复制;平台公共题库后续更新不会自动进入租户题库,后续会由 worker 做版本同步、冲突处理和租户确认。 + ## 登录对接 ### 短信登录 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index eaca1d80..531a783a 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -15,6 +15,7 @@ const TENANT_SALES_USER_ID = '00000000-0000-0000-0000-000000000104'; const TENANT_AGENT_USER_ID = '00000000-0000-0000-0000-000000000105'; const TENANT_TEACHER_USER_ID = '00000000-0000-0000-0000-000000000106'; const SECOND_STUDENT_USER_ID = '00000000-0000-0000-0000-000000000107'; +const PARTNER_TENANT_ADMIN_USER_ID = '00000000-0000-0000-0000-000000000907'; const AUTH_USER_ID = '00000000-0000-0000-0000-00000000a101'; const AUTH_TENANT_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a102'; const AUTH_PLATFORM_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a999'; @@ -48,6 +49,8 @@ const ids = { tenantClassOther: '00000000-0000-0000-0000-000000000852', examDate: '00000000-0000-0000-0000-000000000861', tenantExamDate: '00000000-0000-0000-0000-000000000862', + questionBank: '00000000-0000-0000-0000-000000000400', + publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906', }; const paymentFixture = (() => { @@ -2404,6 +2407,115 @@ async function testTenantContentAssetsAndImports() { assert.equal(partnerImports.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'import jobs must be tenant isolated'); } +async function testPublicQuestionBankAdoption() { + const studentDenied = await request('/api/tenant-content/public-question-banks', { + expectStatus: 403, + }); + assert.equal(studentDenied.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not browse adoptable public banks'); + + const platformBanks = await request('/api/platform-admin/question-banks', { + userId: false, + headers: { 'x-platform-admin-key': 'local-platform-admin-key' }, + query: { q: '烟测公共题库' }, + }); + assert.ok(platformBanks.items?.some(item => item.id === ids.questionBank && item.sourceScope === 'platform'), 'platform admin should list platform public banks'); + + const grant = await request('/api/platform-admin/question-bank-grants', { + userId: false, + headers: { 'x-platform-admin-key': 'local-platform-admin-key' }, + method: 'PUT', + body: { + id: ids.publicQuestionBankGrant, + sourceQuestionBankId: ids.questionBank, + grantScope: 'plans', + allowedPlanCodes: ['starter_yearly', 'pro_yearly'], + status: 'active', + metadata: { source: 'integration-test' }, + }, + }); + assert.equal(grant.item?.sourceQuestionBankId, ids.questionBank, 'platform admin should upsert public bank grant'); + + const grants = await request('/api/platform-admin/question-bank-grants', { + userId: false, + headers: { 'x-platform-admin-key': 'local-platform-admin-key' }, + query: { questionBankId: ids.questionBank }, + }); + assert.ok(grants.items?.some(item => item.id === ids.publicQuestionBankGrant), 'platform admin should list public bank grants'); + + const partnerBanks = await request('/api/tenant-content/public-question-banks', { + tenantId: PARTNER_TENANT_ID, + userId: PARTNER_TENANT_ADMIN_USER_ID, + }); + const adoptable = partnerBanks.items?.find(item => item.grantId === ids.publicQuestionBankGrant); + assert.ok(adoptable, 'partner tenant should see public bank granted by SaaS plan'); + assert.equal(adoptable.adoptedId, null, 'public bank should start as not adopted after smoke seed'); + + const adopted = await request('/api/tenant-content/public-question-banks/adopt', { + tenantId: PARTNER_TENANT_ID, + userId: PARTNER_TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + grantId: ids.publicQuestionBankGrant, + entryName: '合作商采纳烟测公共题库', + collectionName: '合作商公共题库题目', + copyLimit: 3, + metadata: { source: 'integration-test' }, + }, + }); + assert.equal(adopted.item?.sourceQuestionBankId, ids.questionBank, 'adoption should bind source public question bank'); + assert.equal(adopted.item?.copiedQuestionCount, 3, 'adoption should copy a snapshot of published questions'); + assert.ok(adopted.item?.targetEntryId, 'adoption should create tenant content entry'); + assert.ok(adopted.item?.targetCollectionId, 'adoption should create tenant question collection'); + + const repeated = await request('/api/tenant-content/public-question-banks/adopt', { + tenantId: PARTNER_TENANT_ID, + userId: PARTNER_TENANT_ADMIN_USER_ID, + method: 'POST', + body: { grantId: ids.publicQuestionBankGrant }, + expectStatus: 409, + }); + assert.equal(repeated.code, 'QUESTION_BANK_ALREADY_ADOPTED', 'tenant should not adopt the same public bank twice'); + + const adoptedList = await request('/api/tenant-content/public-question-banks', { + tenantId: PARTNER_TENANT_ID, + userId: PARTNER_TENANT_ADMIN_USER_ID, + }); + const adoptedItem = adoptedList.items?.find(item => item.grantId === ids.publicQuestionBankGrant); + assert.equal(adoptedItem?.adoptionStatus, 'active', 'adoptable list should expose adoption status'); + + const entries = await request('/api/catalog/content-entries', { + tenantId: PARTNER_TENANT_ID, + userId: false, + query: { entryType: 'question_practice' }, + }); + assert.ok(entries.items?.some(item => item.id === adopted.item.targetEntryId), 'adopted public bank should appear in tenant catalog entries'); + + const collections = await request('/api/catalog/question-collections', { + tenantId: PARTNER_TENANT_ID, + userId: false, + query: { entryId: adopted.item.targetEntryId }, + }); + assert.ok(collections.items?.some(item => item.id === adopted.item.targetCollectionId && item.questionCount === 3), 'adopted public bank should expose a tenant collection'); + + const session = await request('/api/learning/practice-sessions', { + tenantId: PARTNER_TENANT_ID, + userId: PARTNER_TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + collectionId: adopted.item.targetCollectionId, + mode: 'sequential', + questionLimit: 2, + }, + }); + assert.equal(session.item?.questionCount, 2, 'adopted public bank collection should be usable for practice sessions'); + + const mainTenantNotAdopted = await request('/api/tenant-content/public-question-banks', { + userId: TENANT_ADMIN_USER_ID, + query: { onlyNotAdopted: 'true' }, + }); + assert.ok(!mainTenantNotAdopted.items?.some(item => item.grantId === ids.publicQuestionBankGrant), 'platform-owned main tenant should not see plan grant without matching SaaS subscription'); +} + async function testTenantAdminOps() { const fakeWechat = await startFakeWechatServer(); const denied = await request('/api/tenant-admin/branding', { @@ -3567,6 +3679,7 @@ async function main() { await check('tenant isolation', testTenantIsolation); await check('tenant content admin', testTenantContentAdmin); await check('tenant content assets and imports', testTenantContentAssetsAndImports); + await check('public question bank adoption', testPublicQuestionBankAdoption); await check('tenant admin operations', testTenantAdminOps); await check('tenant member permissions and audit', testTenantMemberPermissionsAndAudit); await check('tenant class and student scopes', testTenantClassStudentScopes); diff --git a/scripts/smoke-seed.js b/scripts/smoke-seed.js index 540c3f2b..8aec8513 100644 --- a/scripts/smoke-seed.js +++ b/scripts/smoke-seed.js @@ -75,6 +75,8 @@ const ids = { partnerInvoice: '00000000-0000-0000-0000-000000000903', partnerInvoiceItem: '00000000-0000-0000-0000-000000000904', partnerInvoicePayment: '00000000-0000-0000-0000-000000000905', + publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906', + partnerTenantAdminUser: '00000000-0000-0000-0000-000000000907', platformAdminUser: '00000000-0000-0000-0000-000000000999', }; @@ -504,6 +506,20 @@ async function main() { [ids.platformAdminUser, ids.authPlatformAdminUser], ); + await client.query( + ` + insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, raw_profile) + values ($1, null, 'smoke_partner_admin', '13800000907', 'Smoke Partner Admin', 'tenant_admin', '{"source":"smoke-seed"}'::jsonb) + on conflict (id) + do update set username = excluded.username, + phone = excluded.phone, + name = excluded.name, + primary_role = excluded.primary_role, + updated_at = now() + `, + [ids.partnerTenantAdminUser], + ); + await client.query( ` insert into public.user_identities (user_id, provider, provider_subject, phone) @@ -526,6 +542,16 @@ async function main() { [tenantId, ids.user], ); + await client.query( + ` + delete from public.tenant_memberships + where tenant_id = $1 + and user_id = $2 + and role = 'tenant_admin' + `, + [ids.partnerTenant, ids.tenantAdminUser], + ); + await client.query( ` insert into public.tenant_memberships (tenant_id, user_id, role, status, permissions) @@ -650,10 +676,13 @@ async function main() { await client.query( ` insert into public.question_banks (id, tenant_id, region_id, name, source_scope, status, metadata) - values ($1, $2, $3, '烟测题库', 'tenant', 'active', '{"source":"smoke-seed"}'::jsonb) + values ($1, $2, $3, '烟测公共题库', 'platform', 'active', '{"source":"smoke-seed","commercialScope":"public_region_bank"}'::jsonb) on conflict (id) do update set name = excluded.name, region_id = excluded.region_id, + source_scope = excluded.source_scope, + status = excluded.status, + metadata = excluded.metadata, updated_at = now() `, [ids.questionBank, tenantId, ids.region], @@ -1784,6 +1813,109 @@ async function main() { [ids.partnerInvoicePayment, ids.partnerTenant, ids.partnerInvoice], ); + await client.query( + ` + insert into public.tenant_memberships (tenant_id, user_id, role, status, permissions) + values ($1, $2, 'tenant_admin', 'active', '{"content:*":true}'::jsonb) + on conflict (tenant_id, user_id, role) + do update set status = 'active', + permissions = excluded.permissions, + updated_at = now() + `, + [ids.partnerTenant, ids.partnerTenantAdminUser], + ); + + await client.query( + ` + insert into public.question_bank_grants ( + id, source_question_bank_id, grant_scope, allowed_plan_codes, + allowed_tenant_ids, status, starts_at, expires_at, metadata + ) + values ( + $1, $2, 'plans', array['starter_yearly','pro_yearly']::text[], + '{}'::uuid[], 'active', '2026-06-21T00:00:00Z', null, + '{"source":"smoke-seed","business":"public_question_bank_authorization"}'::jsonb + ) + on conflict (id) + do update set grant_scope = excluded.grant_scope, + allowed_plan_codes = excluded.allowed_plan_codes, + status = excluded.status, + starts_at = excluded.starts_at, + expires_at = excluded.expires_at, + metadata = excluded.metadata, + updated_at = now() + `, + [ids.publicQuestionBankGrant, ids.questionBank], + ); + + await client.query( + ` + delete from public.tenant_question_bank_adoptions + where tenant_id = $1 + and source_question_bank_id = $2 + `, + [ids.partnerTenant, ids.questionBank], + ); + + await client.query( + ` + delete from public.question_collection_items + where tenant_id = $1 + and metadata->>'source' = 'public_question_bank_adoption' + `, + [ids.partnerTenant], + ); + + await client.query( + ` + delete from public.question_versions + where tenant_id = $1 + and question_id in ( + select id + from public.questions + where tenant_id = $1 + and legacy_id like 'public:%' + ) + `, + [ids.partnerTenant], + ); + + await client.query( + ` + delete from public.questions + where tenant_id = $1 + and legacy_id like 'public:%' + `, + [ids.partnerTenant], + ); + + await client.query( + ` + delete from public.question_collections + where tenant_id = $1 + and metadata->>'source' = 'public_question_bank_adoption' + `, + [ids.partnerTenant], + ); + + await client.query( + ` + delete from public.content_entries + where tenant_id = $1 + and entry_key like 'public-%' + `, + [ids.partnerTenant], + ); + + await client.query( + ` + delete from public.question_banks + where tenant_id = $1 + and metadata->>'source' = 'public_question_bank_adoption' + `, + [ids.partnerTenant], + ); + await client.query( ` delete from public.tenant_usage_records diff --git a/supabase/migrations/202606290007_public_question_bank_adoptions.sql b/supabase/migrations/202606290007_public_question_bank_adoptions.sql new file mode 100644 index 00000000..5c599f94 --- /dev/null +++ b/supabase/migrations/202606290007_public_question_bank_adoptions.sql @@ -0,0 +1,121 @@ +create table if not exists public.question_bank_grants ( + id uuid primary key default gen_random_uuid(), + source_question_bank_id uuid not null references public.question_banks(id) on delete cascade, + grant_scope text not null default 'plans' + check (grant_scope in ('all_active_tenants', 'plans', 'tenants', 'mixed')), + allowed_plan_codes text[] not null default '{}'::text[], + allowed_tenant_ids uuid[] not null default '{}'::uuid[], + allowed_region_ids uuid[] not null default '{}'::uuid[], + allowed_subject_ids uuid[] not null default '{}'::uuid[], + status text not null default 'active' check (status in ('active', 'disabled', 'expired')), + starts_at timestamptz, + expires_at timestamptz, + metadata jsonb not null default '{}'::jsonb, + created_by uuid references public.platform_users(id) on delete set null, + updated_by uuid references public.platform_users(id) on delete set null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists public.tenant_question_bank_adoptions ( + id uuid primary key default gen_random_uuid(), + tenant_id uuid not null references public.tenants(id) on delete cascade, + source_question_bank_id uuid not null references public.question_banks(id) on delete cascade, + grant_id uuid references public.question_bank_grants(id) on delete set null, + target_question_bank_id uuid references public.question_banks(id) on delete set null, + target_entry_id uuid references public.content_entries(id) on delete set null, + target_collection_id uuid references public.question_collections(id) on delete set null, + adoption_mode text not null default 'copied_snapshot' + check (adoption_mode in ('copied_snapshot', 'reference')), + status text not null default 'active' check (status in ('active', 'sync_pending', 'suspended', 'archived')), + sync_status text not null default 'synced' check (sync_status in ('pending', 'synced', 'failed')), + source_snapshot jsonb not null default '{}'::jsonb, + copied_question_count integer not null default 0 check (copied_question_count >= 0), + metadata jsonb not null default '{}'::jsonb, + created_by uuid references public.platform_users(id) on delete set null, + updated_by uuid references public.platform_users(id) on delete set null, + last_synced_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (tenant_id, source_question_bank_id) +); + +create index if not exists idx_question_bank_grants_source_status + on public.question_bank_grants(source_question_bank_id, status, starts_at, expires_at); + +create index if not exists idx_question_bank_grants_allowed_tenants + on public.question_bank_grants using gin(allowed_tenant_ids); + +create index if not exists idx_question_bank_grants_allowed_plans + on public.question_bank_grants using gin(allowed_plan_codes); + +create index if not exists idx_tenant_question_bank_adoptions_tenant + on public.tenant_question_bank_adoptions(tenant_id, status, updated_at desc); + +alter table public.question_bank_grants enable row level security; +alter table public.tenant_question_bank_adoptions enable row level security; + +drop policy if exists question_bank_grants_platform_admin on public.question_bank_grants; +create policy question_bank_grants_platform_admin on public.question_bank_grants + for all + using (app.is_platform_admin()) + with check (app.is_platform_admin()); + +drop policy if exists question_bank_grants_eligible_tenant_read on public.question_bank_grants; +create policy question_bank_grants_eligible_tenant_read on public.question_bank_grants + for select + using ( + status = 'active' + and (starts_at is null or starts_at <= now()) + and (expires_at is null or expires_at > now()) + and exists ( + select 1 + from public.question_banks qb + where qb.id = source_question_bank_id + and qb.source_scope = 'platform' + and qb.status = 'active' + ) + and ( + ( + grant_scope = 'all_active_tenants' + and exists ( + select 1 + from public.tenant_subscriptions ts + where ts.tenant_id = app.current_tenant_id() + and ts.status in ('trial', 'active') + and (ts.expires_at is null or ts.expires_at > now()) + ) + ) + or ( + grant_scope in ('plans', 'mixed') + and exists ( + select 1 + from public.tenant_subscriptions ts + where ts.tenant_id = app.current_tenant_id() + and ts.status in ('trial', 'active') + and (ts.expires_at is null or ts.expires_at > now()) + and ts.plan_code = any(allowed_plan_codes) + ) + ) + or ( + grant_scope in ('tenants', 'mixed') + and app.current_tenant_id() = any(allowed_tenant_ids) + ) + ) + ); + +drop policy if exists tenant_question_bank_adoptions_isolation on public.tenant_question_bank_adoptions; +create policy tenant_question_bank_adoptions_isolation on public.tenant_question_bank_adoptions + 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()); + +drop trigger if exists set_updated_at on public.question_bank_grants; +create trigger set_updated_at + before update on public.question_bank_grants + for each row execute function app.touch_updated_at(); + +drop trigger if exists set_updated_at on public.tenant_question_bank_adoptions; +create trigger set_updated_at + before update on public.tenant_question_bank_adoptions + for each row execute function app.touch_updated_at();