forked from wangziqi/gongxue-base
feat: add public question bank adoption
This commit is contained in:
@@ -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、积分活动深化,以及排行榜防刷/预聚合。
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<string, unknown>, 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);
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
646
apps/api/src/features/tenant-content/public-banks.ts
Normal file
646
apps/api/src/features/tenant-content/public-banks.ts
Normal file
@@ -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<string, unknown>;
|
||||
}>(
|
||||
`
|
||||
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<EligibleBankRow>(
|
||||
`
|
||||
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<EligibleBankRow>(
|
||||
`
|
||||
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 };
|
||||
}
|
||||
@@ -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 私有地址。
|
||||
|
||||
@@ -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 做增量同步、冲突处理、版本升级通知和租户自改保护 |
|
||||
|
||||
## 当前验证
|
||||
|
||||
|
||||
@@ -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 活跃。
|
||||
|
||||
@@ -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 择校推荐报告、排行榜防刷/预聚合、勋章自动发放。
|
||||
|
||||
@@ -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:增强体验
|
||||
|
||||
|
||||
@@ -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. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||
|
||||
@@ -181,6 +181,7 @@ tenant:<tenantId>: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 做版本同步、冲突处理和租户确认。
|
||||
|
||||
## 登录对接
|
||||
|
||||
### 短信登录
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user