forked from wangziqi/gongxue-base
feat: enforce public bank SaaS scopes
This commit is contained in:
@@ -1039,9 +1039,9 @@ export async function upsertQuestionBankGrantRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const bankResult = await client.query<{ id: string }>(
|
||||
const bankResult = await client.query<{ id: string; tenantId: string; regionId: string | null }>(
|
||||
`
|
||||
select id
|
||||
select id, tenant_id as "tenantId", region_id as "regionId"
|
||||
from public.question_banks
|
||||
where id = $1
|
||||
and source_scope = 'platform'
|
||||
@@ -1052,6 +1052,45 @@ export async function upsertQuestionBankGrantRoute(ctx: RequestContext) {
|
||||
if (!bankResult.rows[0]) {
|
||||
throw new HttpError(404, 'Platform question bank not found', 'PLATFORM_QUESTION_BANK_NOT_FOUND');
|
||||
}
|
||||
const sourceBank = bankResult.rows[0];
|
||||
|
||||
if (allowedRegionIds.length) {
|
||||
const regionCount = await client.query<{ count: string }>(
|
||||
'select count(*)::text as count from public.regions where tenant_id = $1 and id = any($2::uuid[])',
|
||||
[sourceBank.tenantId, allowedRegionIds],
|
||||
);
|
||||
if (Number(regionCount.rows[0]?.count || 0) !== allowedRegionIds.length) {
|
||||
throw new HttpError(400, 'One or more allowed regions do not belong to the source platform tenant', 'PUBLIC_BANK_REGION_NOT_FOUND');
|
||||
}
|
||||
if (sourceBank.regionId && !allowedRegionIds.includes(sourceBank.regionId)) {
|
||||
throw new HttpError(400, 'allowedRegionIds must include the source question bank region', 'PUBLIC_BANK_REGION_SCOPE_MISMATCH');
|
||||
}
|
||||
}
|
||||
|
||||
if (allowedSubjectIds.length) {
|
||||
const subjectCount = await client.query<{ count: string }>(
|
||||
'select count(*)::text as count from public.subjects where tenant_id = $1 and id = any($2::uuid[])',
|
||||
[sourceBank.tenantId, allowedSubjectIds],
|
||||
);
|
||||
if (Number(subjectCount.rows[0]?.count || 0) !== allowedSubjectIds.length) {
|
||||
throw new HttpError(400, 'One or more allowed subjects do not belong to the source platform tenant', 'PUBLIC_BANK_SUBJECT_NOT_FOUND');
|
||||
}
|
||||
const outOfScopeSubjects = await client.query<{ count: string }>(
|
||||
`
|
||||
select count(distinct q.subject_id)::text as count
|
||||
from public.questions q
|
||||
where q.tenant_id = $1
|
||||
and q.question_bank_id = $2
|
||||
and q.status = 'published'
|
||||
and q.subject_id is not null
|
||||
and not q.subject_id = any($3::uuid[])
|
||||
`,
|
||||
[sourceBank.tenantId, questionBankId, allowedSubjectIds],
|
||||
);
|
||||
if (Number(outOfScopeSubjects.rows[0]?.count || 0) > 0) {
|
||||
throw new HttpError(400, 'allowedSubjectIds do not cover all published questions in this bank', 'PUBLIC_BANK_SUBJECT_SCOPE_MISMATCH');
|
||||
}
|
||||
}
|
||||
|
||||
if (allowedPlanCodes.length) {
|
||||
const planCount = await client.query<{ count: string }>(
|
||||
@@ -1131,6 +1170,8 @@ export async function upsertQuestionBankGrantRoute(ctx: RequestContext) {
|
||||
grantScope,
|
||||
allowedPlanCodes,
|
||||
allowedTenantIds,
|
||||
allowedRegionIds,
|
||||
allowedSubjectIds,
|
||||
status,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -15,6 +15,10 @@ interface EligibleBankRow {
|
||||
sourceRegionName: string | null;
|
||||
grantScope: string;
|
||||
allowedPlanCodes: string[];
|
||||
allowedRegionIds: string[];
|
||||
allowedSubjectIds: string[];
|
||||
accessPlanCode: string | null;
|
||||
accessMode: string | null;
|
||||
questionCount: number;
|
||||
adoptedId: string | null;
|
||||
adoptionStatus: string | null;
|
||||
@@ -109,6 +113,80 @@ export interface PublicQuestionBankSyncInput {
|
||||
workerId?: string | null;
|
||||
}
|
||||
|
||||
const PUBLIC_BANK_ACCESS_CTES = `
|
||||
with active_subscriptions as (
|
||||
select s.plan_code, s.metadata, coalesce(p.feature_flags, '{}'::jsonb) as plan_feature_flags
|
||||
from public.tenant_subscriptions s
|
||||
left join public.platform_saas_plans p on p.code = s.plan_code
|
||||
where s.tenant_id = $1
|
||||
and s.status in ('trial', 'active')
|
||||
and (s.expires_at is null or s.expires_at > now())
|
||||
),
|
||||
source_subjects as (
|
||||
select q.question_bank_id, array_agg(distinct q.subject_id) filter (where q.subject_id is not null) as subject_ids
|
||||
from public.questions q
|
||||
where q.status = 'published'
|
||||
group by q.question_bank_id
|
||||
),
|
||||
eligible_grants as (
|
||||
select g.*,
|
||||
access.plan_code as access_plan_code,
|
||||
access.access_mode
|
||||
from public.question_bank_grants g
|
||||
join public.question_banks source_qb
|
||||
on source_qb.id = g.source_question_bank_id
|
||||
and source_qb.source_scope = 'platform'
|
||||
and source_qb.status = 'active'
|
||||
left join source_subjects ss on ss.question_bank_id = source_qb.id
|
||||
left join lateral (
|
||||
select s.plan_code,
|
||||
coalesce(
|
||||
nullif(s.metadata #>> '{publicQuestionBankAccess,mode}', ''),
|
||||
nullif(s.plan_feature_flags #>> '{publicQuestionBanks,mode}', ''),
|
||||
'all'
|
||||
) as access_mode
|
||||
from active_subscriptions s
|
||||
where app.public_question_bank_subscription_allows(
|
||||
s.metadata,
|
||||
s.plan_feature_flags,
|
||||
source_qb.id,
|
||||
source_qb.region_id,
|
||||
coalesce(ss.subject_ids, '{}'::uuid[])
|
||||
)
|
||||
order by case when g.grant_scope in ('plans', 'mixed') and s.plan_code = any(g.allowed_plan_codes) then 0 else 1 end,
|
||||
s.plan_code
|
||||
limit 1
|
||||
) access on true
|
||||
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 app.public_question_bank_grant_allows(
|
||||
g.allowed_region_ids,
|
||||
g.allowed_subject_ids,
|
||||
source_qb.region_id,
|
||||
coalesce(ss.subject_ids, '{}'::uuid[])
|
||||
)
|
||||
and (
|
||||
(
|
||||
g.grant_scope = 'all_active_tenants'
|
||||
and access.plan_code is not null
|
||||
)
|
||||
or (
|
||||
g.grant_scope in ('plans', 'mixed')
|
||||
and access.plan_code = any(g.allowed_plan_codes)
|
||||
)
|
||||
or (
|
||||
g.grant_scope in ('tenants', 'mixed')
|
||||
and $1 = any(g.allowed_tenant_ids)
|
||||
and (
|
||||
g.metadata->>'requiresActiveSubscription' = 'false'
|
||||
or access.plan_code is not null
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
`;
|
||||
|
||||
function slugFromName(name: string) {
|
||||
const ascii = name
|
||||
.normalize('NFKD')
|
||||
@@ -201,13 +279,7 @@ async function loadEligibleGrant(
|
||||
) {
|
||||
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())
|
||||
)
|
||||
${PUBLIC_BANK_ACCESS_CTES}
|
||||
select g.id as "grantId",
|
||||
qb.id as "sourceQuestionBankId",
|
||||
qb.name as "sourceQuestionBankName",
|
||||
@@ -216,6 +288,10 @@ async function loadEligibleGrant(
|
||||
r.name as "sourceRegionName",
|
||||
g.grant_scope as "grantScope",
|
||||
g.allowed_plan_codes as "allowedPlanCodes",
|
||||
g.allowed_region_ids as "allowedRegionIds",
|
||||
g.allowed_subject_ids as "allowedSubjectIds",
|
||||
g.access_plan_code as "accessPlanCode",
|
||||
g.access_mode as "accessMode",
|
||||
coalesce(qs.question_count, 0)::integer as "questionCount",
|
||||
a.id as "adoptedId",
|
||||
a.status as "adoptionStatus",
|
||||
@@ -225,7 +301,7 @@ async function loadEligibleGrant(
|
||||
a.target_collection_id as "targetCollectionId",
|
||||
a.copied_question_count as "copiedQuestionCount",
|
||||
a.last_synced_at as "lastSyncedAt"
|
||||
from public.question_bank_grants g
|
||||
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 lateral (
|
||||
@@ -239,29 +315,6 @@ async function loadEligibleGrant(
|
||||
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],
|
||||
@@ -805,38 +858,7 @@ export async function publicQuestionBanksRoute(ctx: RequestContext) {
|
||||
|
||||
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)
|
||||
)
|
||||
)
|
||||
)
|
||||
${PUBLIC_BANK_ACCESS_CTES}
|
||||
select g.id as "grantId",
|
||||
qb.id as "sourceQuestionBankId",
|
||||
qb.name as "sourceQuestionBankName",
|
||||
@@ -845,6 +867,10 @@ export async function publicQuestionBanksRoute(ctx: RequestContext) {
|
||||
r.name as "sourceRegionName",
|
||||
g.grant_scope as "grantScope",
|
||||
g.allowed_plan_codes as "allowedPlanCodes",
|
||||
g.allowed_region_ids as "allowedRegionIds",
|
||||
g.allowed_subject_ids as "allowedSubjectIds",
|
||||
g.access_plan_code as "accessPlanCode",
|
||||
g.access_mode as "accessMode",
|
||||
coalesce(qs.question_count, 0)::integer as "questionCount",
|
||||
a.id as "adoptedId",
|
||||
a.status as "adoptionStatus",
|
||||
|
||||
@@ -147,8 +147,8 @@
|
||||
| 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 |
|
||||
| 平台租户/详情/账务资料/员工/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持当前平台账号权限目录、平台员工列表、平台员工创建/编辑、平台员工禁用/恢复、租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志查询、CSV/JSON 审计导出、平台审计告警规则查询、告警列表、确认/解决/忽略、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款、逾期标记、内部催缴台账、催缴外部通知渠道和发送事件、用量;平台 API 已拆分 `platform:staff:read/write/status`、`platform:tenant:read/write/status/billing_profile`、`platform:billing:read/write/payment/dunning/notification`、`platform:audit:read/export/alert/notification`、`platform:question_bank:read/grant` 等权限点;审计导出、平台员工操作、告警响应和通知事件都会对 `details`/payload 中的 token/secret/password/key 等敏感字段递归脱敏;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`apps/worker --job platform-audit-notifications` 会按 `platform_audit_notification_channels` 把开放告警推送到 generic/钉钉/飞书/企微 webhook,签名密钥放 `app_private.platform_secrets` 且 API 不回显原文;`apps/worker --job platform-dunning-notifications` 会按 `platform_dunning_notification_channels` 把内部催缴记录推送到 generic/钉钉/飞书/企微 webhook,发送成功会推进提醒状态,失败会退避重试,联系方式和请求 payload 会脱敏;创建租户、平台员工变更、状态变更、账务资料维护、订阅批量开票、自动开票、逾期催缴、手工收款确认、审计导出、告警状态更新、通知渠道变更和催缴通知渠道变更会写入审计 |
|
||||
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
|
||||
| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 |
|
||||
| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,新增/更新和冲突会生成租户内容通知;租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会重新校验授权并写入逐条审计,冲突全部处理后相关通知自动 resolved |
|
||||
| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库,并可限制授权地区和科目。平台保存 grant 时会校验 `allowedRegionIds`、`allowedSubjectIds` 属于源平台题库租户,且已发布题目的科目必须被授权科目覆盖 |
|
||||
| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`;租户只能看到自己 `question_bank_grants`、有效 `tenant_subscriptions`、`platform_saas_plans.feature_flags.publicQuestionBanks` 和订阅 `metadata.publicQuestionBankAccess` 同时允许的题库。基础版默认 `limited_regions` 且需要地区 allowlist,专业版默认 `national`;采纳、同步和冲突处理都会重新校验当前授权,越权 grant 返回 `QUESTION_BANK_GRANT_NOT_AVAILABLE`。采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,新增/更新和冲突会生成租户内容通知;租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会写入逐条审计,冲突全部处理后相关通知自动 resolved |
|
||||
| 题库导出 | 可联调 | `/api/tenant-content/exports/questions`、`/api/tenant-content/exports/jobs`;支持按题目集合、内容入口或分类节点导出 JSON/试卷 payload,也支持 `pdf/docx/daily_practice_zip` 异步导出;`exportType=daily_practice` 可生成每日一练九宫格运营素材 metadata、PDF/Word 基础版式和 ZIP 图片素材包;后端校验租户内容编辑权限、跨租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;`apps/worker --job exports` 负责 PDF/Word/ZIP 渲染、生成 `content_assets`、记录 hash/size/assetId,前端通过资源签名接口下载/预览 |
|
||||
|
||||
## 销售、代理、CRM
|
||||
@@ -173,7 +173,7 @@
|
||||
| PocketBase schema/导出分析 | 可联调 | `scripts/import-pocketbase` 支持 schema summary/risk、`npm run pb:import:dry-run` 导出目录静态迁移报告 |
|
||||
| PocketBase JSON dry-run | 可联调 | 不写数据库,检查导出目录、JSON 形态、核心集合、旧 ID、敏感字段、schema relation、未映射集合和关键业务计数;`--profile=production` 会额外检查生产迁移必需集合和关键字段覆盖率,正式切换建议配合 `--fail-on-warnings` |
|
||||
| 题目 JSON preview/import | 可联调 | 后端负责规范化、issue、幂等、审计 |
|
||||
| 公共题库采纳、手动同步和自动同步 | 可联调 | 平台授权后,租户可采纳公共题库并复制已发布题目快照;同步 API 和 `public-banks` worker 支持新增/更新题目、重新校验授权、跨租户拒绝、审计记录、租户内容通知和租户自改冲突保护;冲突处理 API 已支持单条/批量采纳平台版本和保留租户本地版本;已覆盖跨租户、重复采纳、采纳后组卷、同步新增题、通知隔离/已读/自动 resolved、冲突不覆盖、单条/批量冲突处理和 worker 自动同步测试 |
|
||||
| 公共题库采纳、手动同步和自动同步 | 可联调 | 平台授权后,租户可采纳公共题库并复制已发布题目快照;同步 API 和 `public-banks` worker 支持新增/更新题目、重新校验授权、跨租户拒绝、审计记录、租户内容通知和租户自改冲突保护;冲突处理 API 已支持单条/批量采纳平台版本和保留租户本地版本;已覆盖跨租户、重复采纳、采纳后组卷、同步新增题、通知隔离/已读/自动 resolved、冲突不覆盖、单条/批量冲突处理、worker 自动同步、starter 单地区不可见/不可采纳第二地区题库、pro 全国套餐可见第二地区题库等测试 |
|
||||
| 单词 JSON preview/import | 可联调 | 兼容旧模板 |
|
||||
| 知识手册 JSON preview/import | 可联调 | 支持书籍/章节/小节/知识点归一化 |
|
||||
| 分数线 JSON preview/import | 可联调 | 支持 `fields/schools/majors/records` 分桶或 `items` 列表,后端校验租户地区和院校/专业引用 |
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
| 模块 | 当前状态 | 已经具备 | 上线前还要补 |
|
||||
| --- | --- | --- | --- |
|
||||
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收;`npm run smoke:auth:remote` 已提供真实云端 Supabase access token 回归脚本 | 真实云端 Auth/JWKS 回归需要在预生产/生产环境执行并留档,生产 RLS 深测继续执行 |
|
||||
| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台账号细粒度权限目录、平台员工列表/创建/编辑/启停、平台路由权限强校验、平台审计日志查询、平台审计 CSV/JSON 导出、平台审计告警规则/列表/确认/解决、platform-audit-alerts worker、审计告警外部通知渠道/事件 API、platform-audit-notifications worker、套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费、人工收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件 API、platform-dunning-notifications worker、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 平台在线收款、平台审计告警升级策略和更完整运营消息 |
|
||||
| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台账号细粒度权限目录、平台员工列表/创建/编辑/启停、平台路由权限强校验、平台审计日志查询、平台审计 CSV/JSON 导出、平台审计告警规则/列表/确认/解决、platform-audit-alerts worker、审计告警外部通知渠道/事件 API、platform-audit-notifications worker、套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费、人工收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件 API、platform-dunning-notifications worker、用量、公共题库授权、公共题库地区/科目授权校验、SaaS 套餐/订阅 metadata 公共题库访问边界、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 平台在线收款、平台审计告警升级策略和更完整运营消息 |
|
||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 |
|
||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、starter 单地区/专业版全国公共题库访问边界、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 |
|
||||
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
||||
| 知识手册 | 可联调 | 科目、章节、条目、Markdown 内容、嵌套 JSON/CSV/Excel 导入 | 富文本资源、版本管理、附件/PDF 关联 |
|
||||
| 分数线 | 可联调 | 院校、专业、动态字段、记录、年份、趋势、后台维护、JSON/CSV/Excel 导入,AI 择校推荐已可读取地区/分数线上下文 | 复杂筛选和更细 AI 推荐运营配置 |
|
||||
@@ -86,7 +86,7 @@
|
||||
- 资金对账已支持手工/API 账单导入比对、微信/支付宝官方账单下载任务、异常查询和差错工单处理;继续补真实生产账单格式验收、财务复核报表和异常订单运营台。
|
||||
- XPay 或其它实际支付网关 adapter。
|
||||
- 阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录真实账号联调。
|
||||
- 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有同步通知、单条/批量冲突采纳平台或保留本地操作;继续补生产定时调度、失败告警,以及租户按 SaaS 套餐购买地区、科目和题库范围的更细计费策略。
|
||||
- 公共题库/地区题库自动同步 worker 已具备单批执行能力,租户后台已有同步通知、单条/批量冲突采纳平台或保留本地操作;公共题库可见、采纳、同步、冲突处理会统一校验 SaaS 套餐、有效订阅、订阅 metadata、grant 地区和科目范围。继续补生产定时调度、失败告警,以及存储、学生数、题量、视频播放量等更多套餐用量限制和超额计费策略。
|
||||
- 导入模板、字段映射、导入任务详情和复检 API 已可用;Taro 租户内容页已接模板下载、字段别名覆盖、导入执行、异步 job 轮询和复检结果面板第一版。前端继续补真实导入目标选择体验和大数据量导入验收。
|
||||
- 视频深度防盗链、转码级水印和播放统计。
|
||||
- 数据看板 API:收益、注册趋势、答题次数、收入趋势、题型分布、题目总量、套餐销量、24h 活跃。
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
|
||||
| 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台账号细粒度权限点、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费收款、逾期标记、内部催缴台账、催缴外部通知、用量记录、公共题库披露策略第一版 | 地区/全国套餐权限细化、平台侧主题模板库、平台在线收款和平台审计报表增强 |
|
||||
| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台账号细粒度权限点、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费收款、逾期标记、内部催缴台账、催缴外部通知、用量记录、公共题库披露策略、公共题库地区/科目授权和基础版单地区/专业版全国访问边界 | 平台侧主题模板库、平台在线收款、平台审计报表增强、套餐存储/学生数/题量超额策略 |
|
||||
| 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 |
|
||||
| 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student,权限矩阵,成员启停,角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 |
|
||||
| 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker、同步通知和冲突查询 API | 字段映射 UI、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 |
|
||||
| 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker、同步通知、冲突查询 API、按 SaaS 套餐和订阅 metadata 控制公共题库地区/科目/题库范围 | 字段映射 UI、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 |
|
||||
| 学生刷题 | 基础完成 | 内容入口、分类树、题目集合、顺序刷题、随机刷题、全真模拟 session 题目快照、答题、错题本、收藏夹、模考交卷评分报告、错题复习计划、排行榜 | 专项练习策略、题型统计深度分析、排行榜防刷/预聚合 |
|
||||
| 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计、每日复习计划、旧模板/新模板 JSON/CSV/Excel 预览导入、内容导航绑定、排行榜 | 更细复习参数 |
|
||||
| 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护、书籍/章节/小节/知识点嵌套 JSON 预览导入、内容导航绑定 | 富文本资源、版本管理、附件/PDF 关联、Excel/Markdown 批量解析 |
|
||||
@@ -37,7 +37,7 @@
|
||||
## 接下来优先级
|
||||
|
||||
1. 完善内容导入和对象存储:字段映射 UI、真实数据 dry-run、CDN 防盗链、真实 AV/内容安全服务联调和转码级视频水印。
|
||||
2. 公共题库/地区题库授权:已完成披露、采纳快照、手动同步、自动同步 worker、同步通知、冲突查询和租户自改保护;继续补生产失败告警、冲突操作台增强和按 SaaS 套餐限制地区。
|
||||
2. 公共题库/地区题库授权:已完成披露、采纳快照、手动同步、自动同步 worker、同步通知、冲突查询、租户自改保护,以及按 SaaS 套餐/订阅 metadata 限制公共题库地区、科目和题库范围;继续补生产失败告警和冲突操作台增强。
|
||||
3. 学习统计增强:排行榜防刷/预聚合、断点续练、专项练习策略和更细题型分析。
|
||||
4. 视频会员控制:深度防盗链、转码级水印和播放统计。
|
||||
5. 数据看板预聚合:把实时聚合升级为大租户可承载的日/周/月预聚合。
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
- 用户站内通知第一版已完成:反馈处理、反馈奖励、勋章发放、积分兑换会创建 `user_notifications`;学生端可查询/标记状态,租户后台具备 `notifications:read` 权限的成员可查看租户内通知;Taro 学生个人中心已接消息筛选、已读和归档第一版,租户营销中心已接用户通知查看和筛选第一版。后续补独立消息中心增强、外部微信订阅消息/短信和批量统计。
|
||||
- 旧商城体验已补齐主链路:订单详情、订单状态轮询、激活码预检查、自用激活码拒绝、优惠券前台领取、下单抵扣、零元订单自动支付开通权益,且手工支付确认已限制为租户后台 `tenant:payment:write` 权限。
|
||||
- 优惠券商用规则已补齐后端和 Taro 租户后台第一版:租户后台可配置启停/归档、活动分组、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区和 metadata;学生领取/下单会由后端复核规则,后台可查核销明细和按活动/日期/券聚合的核销报表。
|
||||
- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权;租户内容管理员只能看到自己被授权的公共题库,并可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,并生成租户内容通知;租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本,冲突处理完成后通知自动 resolved。
|
||||
- 公共题库商业化基础闭环已完成:平台公共题库可由平台管理员按 SaaS 套餐/指定租户/全部活跃租户授权,并可限制授权地区和科目;租户内容管理员只能看到 `question_bank_grants`、有效订阅、SaaS 套餐 `feature_flags.publicQuestionBanks` 和订阅 `metadata.publicQuestionBankAccess` 同时允许的公共题库。基础版默认单地区 allowlist,专业版默认全国可见;越权公共题库不可见且不可直接采纳。租户可采纳为本租户题库、内容入口、题目集合和题目快照,采纳后可直接进入练习 session;平台题库后续新增/更新题目可通过手动同步 API 或 `public-banks` worker 进入租户副本,并生成租户内容通知;租户自改题目会返回冲突并保留原内容,后台可查询最近一次冲突明细,并可单条或批量选择采纳平台版本/保留本地版本,冲突处理完成后通知自动 resolved。
|
||||
- 租户后台数据看板已完成首版聚合 API:`GET /api/tenant-admin/dashboard`,支持租户/地区维度的收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态,前端可直接联调。
|
||||
- 支付/退款补偿 worker 已完成:`apps/worker --job commerce` 可查询微信/支付宝支付和处理中退款,补偿漏通知订单,支付成功幂等开通权益,退款成功幂等更新退款/订单/支付并在全额退款时撤销订单权益。
|
||||
- 资金对账和差错工单闭环已完成:`commerce_reconciliation_batches/items` 和 `/api/commerce/reconciliation/*` 支持手工/API 导入供应商账单行、预览差异、生成批次统计、查询异常、租户隔离、权限点 `tenant:reconciliation:read/write` 和审计日志;`commerce_reconciliation_issues/events` 支持异常明细创建工单、分配、开始处理、升级、解决、忽略、重开和事件留痕,且不直接修改订单/支付/退款/权益。
|
||||
@@ -114,7 +114,8 @@
|
||||
5. 公共题库和租户授权
|
||||
- 已完成平台公共题库/地区题库的基础授权、租户采纳、题目快照复制和手动同步。
|
||||
- 已完成 `public-banks` worker 自动同步、失败记录、审计、同步通知、冲突查询 API 和单条/批量冲突处理 API。
|
||||
- 继续补按 SaaS 套餐限制地区数量、科目范围、题库范围的更细计费策略。
|
||||
- 已完成基础 SaaS 套餐访问边界:`starter_yearly` 默认 `limited_regions` + 地区 allowlist,`pro_yearly` 默认 `national`;租户订阅 metadata 可进一步限制 allowedRegionIds、allowedSubjectIds、allowedQuestionBankIds。
|
||||
- 继续补存储、学生数、题量、视频播放量等更多套餐用量限制和超额计费策略。
|
||||
- 继续补生产定时调度、失败告警和更完整运营后台消息。
|
||||
|
||||
6. 视频会员控制
|
||||
|
||||
@@ -1737,7 +1737,7 @@ POST /api/tenant-content/notifications/status
|
||||
|
||||
前端处理规则:
|
||||
|
||||
- 租户只能看到后端判定为已授权的公共题库,不要在前端用套餐码自行过滤。
|
||||
- 租户只能看到后端判定为已授权的公共题库,不要在前端用套餐码自行过滤。后端会同时校验 `question_bank_grants`、有效 `tenant_subscriptions`、`platform_saas_plans.feature_flags.publicQuestionBanks` 和订阅 `metadata.publicQuestionBankAccess`;基础版默认只允许订阅 metadata 中 `allowedRegionIds` 覆盖的单地区公共题库,专业版默认 `national` 可见全国地区题库。列表响应中的 `accessPlanCode` 和 `accessMode` 仅用于展示“由哪个 SaaS 套餐授予访问”,不能作为前端权限来源。
|
||||
- 采纳成功后后端会生成本租户自己的 `questionBankId`、`entryId`、`collectionId` 和题目快照,学生端直接按普通 `/api/catalog/content-entries`、`question-collections`、`practice-sessions` 接入。
|
||||
- 重复采纳返回 `QUESTION_BANK_ALREADY_ADOPTED`,前端展示“已采纳”即可。
|
||||
- 已采纳公共题库可以手动同步平台后续新增/更新题目;同步会重新校验当前租户仍有授权,且只写入租户自己的题目副本。
|
||||
|
||||
@@ -63,7 +63,13 @@ const ids = {
|
||||
pointExchangeItem: '00000000-0000-0000-0000-000000000879',
|
||||
pointExpensiveExchangeItem: '00000000-0000-0000-0000-000000000880',
|
||||
questionBank: '00000000-0000-0000-0000-000000000400',
|
||||
secondPublicRegion: '00000000-0000-0000-0000-0000000003f1',
|
||||
secondPublicQuestionBank: '00000000-0000-0000-0000-0000000004f1',
|
||||
secondPublicQuestion: '00000000-0000-0000-0000-0000000004f2',
|
||||
secondPublicQuestionVersion: '00000000-0000-0000-0000-0000000004f3',
|
||||
secondPublicQuestionBankGrant: '00000000-0000-0000-0000-0000000009f1',
|
||||
publicQuestionBankGrant: '00000000-0000-0000-0000-000000000906',
|
||||
partnerSubscription: '00000000-0000-0000-0000-000000000902',
|
||||
platformOverdueInvoice: crypto.randomUUID(),
|
||||
};
|
||||
|
||||
@@ -6504,6 +6510,104 @@ async function testPublicQuestionBankAdoption() {
|
||||
});
|
||||
assert.equal(studentDenied.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not browse adoptable public banks');
|
||||
|
||||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||||
try {
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.regions (id, tenant_id, legacy_id, name, code, sort_order, is_active)
|
||||
values ($1, $2, 'integration-second-region', '烟测第二地区', 'SMOKE-2', 2, true)
|
||||
on conflict (id)
|
||||
do update set tenant_id = excluded.tenant_id,
|
||||
name = excluded.name,
|
||||
code = excluded.code,
|
||||
sort_order = excluded.sort_order,
|
||||
is_active = excluded.is_active
|
||||
`,
|
||||
[ids.secondPublicRegion, MAIN_TENANT_ID],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.question_banks (id, tenant_id, region_id, name, source_scope, status, metadata)
|
||||
values ($1, $2, $3, '烟测第二地区公共题库', 'platform', 'active', '{"source":"integration-test","commercialScope":"second_region_bank"}'::jsonb)
|
||||
on conflict (id)
|
||||
do update set tenant_id = excluded.tenant_id,
|
||||
region_id = excluded.region_id,
|
||||
name = excluded.name,
|
||||
source_scope = excluded.source_scope,
|
||||
status = excluded.status,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.secondPublicQuestionBank, MAIN_TENANT_ID, ids.secondPublicRegion],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.questions (
|
||||
id, tenant_id, question_bank_id, subject_id, category_id,
|
||||
legacy_id, type, type_label, difficulty, tags, status
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, 'integration-second-region-question', 'choice', '单选题', 1, '[]'::jsonb, 'published')
|
||||
on conflict (id)
|
||||
do update set question_bank_id = excluded.question_bank_id,
|
||||
subject_id = excluded.subject_id,
|
||||
category_id = excluded.category_id,
|
||||
status = excluded.status,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.secondPublicQuestion, MAIN_TENANT_ID, ids.secondPublicQuestionBank, ids.subject, ids.category],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.question_versions (
|
||||
id, tenant_id, question_id, version_no, content, options,
|
||||
correct_option_index, correct_option_indices, answer_text, explanation, source_hash, created_by
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, 1, '第二地区公共题库题目:2 + 2 = ?',
|
||||
'[{"label":"A","text":"3"},{"label":"B","text":"4"}]'::jsonb,
|
||||
1, '[1]'::jsonb, '4', '第二地区题库只应授权给购买该地区或全国版的租户。',
|
||||
'integration-second-region-v1', $4
|
||||
)
|
||||
on conflict (id)
|
||||
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,
|
||||
source_hash = excluded.source_hash
|
||||
`,
|
||||
[ids.secondPublicQuestionVersion, MAIN_TENANT_ID, ids.secondPublicQuestion, TENANT_ADMIN_USER_ID],
|
||||
);
|
||||
await pool.query(
|
||||
'update public.questions set current_version_id = $2, updated_at = now() where id = $1',
|
||||
[ids.secondPublicQuestion, ids.secondPublicQuestionVersion],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.question_bank_grants (
|
||||
id, source_question_bank_id, grant_scope, allowed_plan_codes,
|
||||
allowed_region_ids, status, metadata
|
||||
)
|
||||
values (
|
||||
$1, $2, 'plans', array['starter_yearly','pro_yearly']::text[],
|
||||
array[$3::uuid]::uuid[], 'active', '{"source":"integration-test","scope":"second_region"}'::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_region_ids = excluded.allowed_region_ids,
|
||||
status = excluded.status,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.secondPublicQuestionBankGrant, ids.secondPublicQuestionBank, ids.secondPublicRegion],
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
const platformBanks = await request('/api/platform-admin/question-banks', {
|
||||
userId: false,
|
||||
headers: { 'x-platform-admin-key': 'local-platform-admin-key' },
|
||||
@@ -6539,7 +6643,85 @@ async function testPublicQuestionBankAdoption() {
|
||||
});
|
||||
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.accessPlanCode, 'starter_yearly', 'adoptable public bank should expose the subscription plan that grants access');
|
||||
assert.equal(adoptable.accessMode, 'limited_regions', 'adoptable public bank should expose limited region access mode');
|
||||
assert.equal(adoptable.adoptedId, null, 'public bank should start as not adopted after smoke seed');
|
||||
assert.equal(
|
||||
partnerBanks.items?.some(item => item.grantId === ids.secondPublicQuestionBankGrant),
|
||||
false,
|
||||
'starter tenant restricted to one region must not see second-region public banks',
|
||||
);
|
||||
|
||||
const secondRegionAdoptDenied = await request('/api/tenant-content/public-question-banks/adopt', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: PARTNER_TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: { grantId: ids.secondPublicQuestionBankGrant },
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(secondRegionAdoptDenied.code, 'QUESTION_BANK_GRANT_NOT_AVAILABLE', 'restricted-region SaaS plan must not adopt out-of-scope public banks');
|
||||
|
||||
const upgradePool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||||
try {
|
||||
const upgradeResult = await upgradePool.query(
|
||||
`
|
||||
update public.tenant_subscriptions
|
||||
set plan_code = 'pro_yearly',
|
||||
metadata = jsonb_build_object(
|
||||
'source', 'integration-test-upgrade',
|
||||
'publicQuestionBankAccess', jsonb_build_object('mode', 'national', 'allowAllRegions', true)
|
||||
),
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
`,
|
||||
[ids.partnerSubscription],
|
||||
);
|
||||
assert.equal(upgradeResult.rowCount, 1, 'integration test should upgrade exactly one partner subscription');
|
||||
const upgradedSubscription = await upgradePool.query(
|
||||
'select plan_code, metadata from public.tenant_subscriptions where id = $1',
|
||||
[ids.partnerSubscription],
|
||||
);
|
||||
assert.equal(upgradedSubscription.rows[0]?.plan_code, 'pro_yearly', 'partner subscription upgrade should persist before public bank list');
|
||||
assert.equal(
|
||||
upgradedSubscription.rows[0]?.metadata?.publicQuestionBankAccess?.mode,
|
||||
'national',
|
||||
'partner subscription upgrade should persist national public bank access mode',
|
||||
);
|
||||
} finally {
|
||||
await upgradePool.end();
|
||||
}
|
||||
|
||||
const upgradedPartnerBanks = await request('/api/tenant-content/public-question-banks', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: PARTNER_TENANT_ADMIN_USER_ID,
|
||||
});
|
||||
const secondRegionVisible = upgradedPartnerBanks.items?.find(item => item.grantId === ids.secondPublicQuestionBankGrant);
|
||||
assert.ok(secondRegionVisible, 'pro/national SaaS subscription should see second-region public banks');
|
||||
assert.equal(secondRegionVisible.accessPlanCode, 'pro_yearly', 'upgraded public bank access should be attributed to pro_yearly');
|
||||
assert.equal(secondRegionVisible.accessMode, 'national', 'upgraded public bank access should expose national mode');
|
||||
|
||||
const restoreSubscriptionPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||||
try {
|
||||
const restoreResult = await restoreSubscriptionPool.query(
|
||||
`
|
||||
update public.tenant_subscriptions
|
||||
set plan_code = 'starter_yearly',
|
||||
metadata = jsonb_build_object(
|
||||
'source', 'integration-test-restore',
|
||||
'publicQuestionBankAccess', jsonb_build_object(
|
||||
'mode', 'limited_regions',
|
||||
'allowedRegionIds', jsonb_build_array($2::text)
|
||||
)
|
||||
),
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
`,
|
||||
[ids.partnerSubscription, ids.region],
|
||||
);
|
||||
assert.equal(restoreResult.rowCount, 1, 'integration test should restore partner subscription after national access check');
|
||||
} finally {
|
||||
await restoreSubscriptionPool.end();
|
||||
}
|
||||
|
||||
const adopted = await request('/api/tenant-content/public-question-banks/adopt', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
|
||||
@@ -2121,15 +2121,26 @@ async function main() {
|
||||
values (
|
||||
$1, $2, 'starter_yearly', 'active',
|
||||
'2026-06-21T00:00:00Z', '2027-06-21T00:00:00Z',
|
||||
'yearly', 980000, '{"source":"smoke-seed"}'::jsonb
|
||||
'yearly', 980000,
|
||||
jsonb_build_object(
|
||||
'source', 'smoke-seed',
|
||||
'publicQuestionBankAccess', jsonb_build_object(
|
||||
'mode', 'limited_regions',
|
||||
'allowedRegionIds', jsonb_build_array($3::text)
|
||||
)
|
||||
)
|
||||
)
|
||||
on conflict (id)
|
||||
do update set status = excluded.status,
|
||||
do update set plan_code = excluded.plan_code,
|
||||
status = excluded.status,
|
||||
starts_at = excluded.starts_at,
|
||||
expires_at = excluded.expires_at,
|
||||
billing_cycle = excluded.billing_cycle,
|
||||
amount_cents = excluded.amount_cents,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = now()
|
||||
`,
|
||||
[ids.partnerSubscription, ids.partnerTenant],
|
||||
[ids.partnerSubscription, ids.partnerTenant, ids.region],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
|
||||
235
supabase/migrations/202606300007_public_bank_saas_scope.sql
Normal file
235
supabase/migrations/202606300007_public_bank_saas_scope.sql
Normal file
@@ -0,0 +1,235 @@
|
||||
create or replace function app.uuid_array_from_jsonb(value jsonb)
|
||||
returns uuid[]
|
||||
language sql
|
||||
immutable
|
||||
as $$
|
||||
select coalesce(array_agg(item::uuid), '{}'::uuid[])
|
||||
from jsonb_array_elements_text(
|
||||
case when jsonb_typeof(value) = 'array' then value else '[]'::jsonb end
|
||||
) as t(item)
|
||||
where item ~* '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
|
||||
$$;
|
||||
|
||||
create or replace function app.text_array_from_jsonb(value jsonb)
|
||||
returns text[]
|
||||
language sql
|
||||
immutable
|
||||
as $$
|
||||
select coalesce(array_agg(item), '{}'::text[])
|
||||
from jsonb_array_elements_text(
|
||||
case when jsonb_typeof(value) = 'array' then value else '[]'::jsonb end
|
||||
) as t(item)
|
||||
where length(item) between 1 and 160
|
||||
$$;
|
||||
|
||||
create or replace function app.public_question_bank_grant_allows(
|
||||
grant_allowed_region_ids uuid[],
|
||||
grant_allowed_subject_ids uuid[],
|
||||
source_region_id uuid,
|
||||
source_subject_ids uuid[]
|
||||
)
|
||||
returns boolean
|
||||
language sql
|
||||
immutable
|
||||
as $$
|
||||
select
|
||||
(
|
||||
coalesce(array_length(grant_allowed_region_ids, 1), 0) = 0
|
||||
or source_region_id = any(grant_allowed_region_ids)
|
||||
)
|
||||
and (
|
||||
coalesce(array_length(grant_allowed_subject_ids, 1), 0) = 0
|
||||
or coalesce(array_length(source_subject_ids, 1), 0) = 0
|
||||
or source_subject_ids <@ grant_allowed_subject_ids
|
||||
)
|
||||
$$;
|
||||
|
||||
create or replace function app.public_question_bank_subscription_allows(
|
||||
subscription_metadata jsonb,
|
||||
plan_feature_flags jsonb,
|
||||
source_question_bank_id uuid,
|
||||
source_region_id uuid,
|
||||
source_subject_ids uuid[]
|
||||
)
|
||||
returns boolean
|
||||
language plpgsql
|
||||
immutable
|
||||
as $$
|
||||
declare
|
||||
plan_cfg jsonb := coalesce(plan_feature_flags #> '{publicQuestionBanks}', '{}'::jsonb);
|
||||
subscription_cfg jsonb := coalesce(subscription_metadata #> '{publicQuestionBankAccess}', '{}'::jsonb);
|
||||
plan_region_ids uuid[] := app.uuid_array_from_jsonb(plan_cfg->'allowedRegionIds');
|
||||
subscription_region_ids uuid[] := app.uuid_array_from_jsonb(subscription_cfg->'allowedRegionIds');
|
||||
plan_subject_ids uuid[] := app.uuid_array_from_jsonb(plan_cfg->'allowedSubjectIds');
|
||||
subscription_subject_ids uuid[] := app.uuid_array_from_jsonb(subscription_cfg->'allowedSubjectIds');
|
||||
plan_bank_ids uuid[] := app.uuid_array_from_jsonb(plan_cfg->'allowedQuestionBankIds');
|
||||
subscription_bank_ids uuid[] := app.uuid_array_from_jsonb(subscription_cfg->'allowedQuestionBankIds');
|
||||
mode text := coalesce(subscription_cfg->>'mode', plan_cfg->>'mode', 'all');
|
||||
requires_region_allowlist boolean :=
|
||||
case coalesce(subscription_cfg->>'requiresRegionAllowlist', plan_cfg->>'requiresRegionAllowlist')
|
||||
when 'true' then true
|
||||
when 'false' then false
|
||||
else false
|
||||
end;
|
||||
allow_all_regions boolean := (
|
||||
case coalesce(subscription_cfg->>'allowAllRegions', plan_cfg->>'allowAllRegions')
|
||||
when 'true' then true
|
||||
when 'false' then false
|
||||
else false
|
||||
end
|
||||
) or mode in ('all', 'all_regions', 'national', 'unlimited');
|
||||
begin
|
||||
if coalesce(subscription_cfg->>'enabled', plan_cfg->>'enabled', 'true') = 'false' then
|
||||
return false;
|
||||
end if;
|
||||
|
||||
if coalesce(array_length(plan_bank_ids, 1), 0) > 0 and not source_question_bank_id = any(plan_bank_ids) then
|
||||
return false;
|
||||
end if;
|
||||
if coalesce(array_length(subscription_bank_ids, 1), 0) > 0 and not source_question_bank_id = any(subscription_bank_ids) then
|
||||
return false;
|
||||
end if;
|
||||
|
||||
if not allow_all_regions and source_region_id is not null then
|
||||
if requires_region_allowlist
|
||||
and coalesce(array_length(plan_region_ids, 1), 0) = 0
|
||||
and coalesce(array_length(subscription_region_ids, 1), 0) = 0 then
|
||||
return false;
|
||||
end if;
|
||||
if coalesce(array_length(plan_region_ids, 1), 0) > 0 and not source_region_id = any(plan_region_ids) then
|
||||
return false;
|
||||
end if;
|
||||
if coalesce(array_length(subscription_region_ids, 1), 0) > 0 and not source_region_id = any(subscription_region_ids) then
|
||||
return false;
|
||||
end if;
|
||||
end if;
|
||||
|
||||
if coalesce(array_length(plan_subject_ids, 1), 0) > 0
|
||||
and coalesce(array_length(source_subject_ids, 1), 0) > 0
|
||||
and not source_subject_ids <@ plan_subject_ids then
|
||||
return false;
|
||||
end if;
|
||||
if coalesce(array_length(subscription_subject_ids, 1), 0) > 0
|
||||
and coalesce(array_length(source_subject_ids, 1), 0) > 0
|
||||
and not source_subject_ids <@ subscription_subject_ids then
|
||||
return false;
|
||||
end if;
|
||||
|
||||
return true;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create index if not exists idx_question_bank_grants_allowed_regions
|
||||
on public.question_bank_grants using gin(allowed_region_ids);
|
||||
|
||||
create index if not exists idx_question_bank_grants_allowed_subjects
|
||||
on public.question_bank_grants using gin(allowed_subject_ids);
|
||||
|
||||
update public.platform_saas_plans
|
||||
set feature_flags = jsonb_set(
|
||||
feature_flags,
|
||||
'{publicQuestionBanks}',
|
||||
'{"enabled":true,"mode":"limited_regions","requiresRegionAllowlist":true,"maxRegionCount":1}'::jsonb,
|
||||
true
|
||||
),
|
||||
updated_at = now()
|
||||
where code = 'starter_yearly';
|
||||
|
||||
update public.platform_saas_plans
|
||||
set feature_flags = jsonb_set(
|
||||
feature_flags,
|
||||
'{publicQuestionBanks}',
|
||||
'{"enabled":true,"mode":"national","allowAllRegions":true}'::jsonb,
|
||||
true
|
||||
),
|
||||
updated_at = now()
|
||||
where code = 'pro_yearly';
|
||||
|
||||
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
|
||||
left join lateral (
|
||||
select array_agg(distinct q.subject_id) filter (where q.subject_id is not null) as subject_ids
|
||||
from public.questions q
|
||||
where q.question_bank_id = qb.id
|
||||
and q.status = 'published'
|
||||
) ss on true
|
||||
where qb.id = source_question_bank_id
|
||||
and qb.source_scope = 'platform'
|
||||
and qb.status = 'active'
|
||||
and app.public_question_bank_grant_allows(
|
||||
allowed_region_ids,
|
||||
allowed_subject_ids,
|
||||
qb.region_id,
|
||||
coalesce(ss.subject_ids, '{}'::uuid[])
|
||||
)
|
||||
and (
|
||||
(
|
||||
grant_scope = 'all_active_tenants'
|
||||
and exists (
|
||||
select 1
|
||||
from public.tenant_subscriptions ts
|
||||
left join public.platform_saas_plans p on p.code = ts.plan_code
|
||||
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 app.public_question_bank_subscription_allows(
|
||||
ts.metadata,
|
||||
coalesce(p.feature_flags, '{}'::jsonb),
|
||||
qb.id,
|
||||
qb.region_id,
|
||||
coalesce(ss.subject_ids, '{}'::uuid[])
|
||||
)
|
||||
)
|
||||
)
|
||||
or (
|
||||
grant_scope in ('plans', 'mixed')
|
||||
and exists (
|
||||
select 1
|
||||
from public.tenant_subscriptions ts
|
||||
left join public.platform_saas_plans p on p.code = ts.plan_code
|
||||
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)
|
||||
and app.public_question_bank_subscription_allows(
|
||||
ts.metadata,
|
||||
coalesce(p.feature_flags, '{}'::jsonb),
|
||||
qb.id,
|
||||
qb.region_id,
|
||||
coalesce(ss.subject_ids, '{}'::uuid[])
|
||||
)
|
||||
)
|
||||
)
|
||||
or (
|
||||
grant_scope in ('tenants', 'mixed')
|
||||
and app.current_tenant_id() = any(allowed_tenant_ids)
|
||||
and (
|
||||
metadata->>'requiresActiveSubscription' = 'false'
|
||||
or exists (
|
||||
select 1
|
||||
from public.tenant_subscriptions ts
|
||||
left join public.platform_saas_plans p on p.code = ts.plan_code
|
||||
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 app.public_question_bank_subscription_allows(
|
||||
ts.metadata,
|
||||
coalesce(p.feature_flags, '{}'::jsonb),
|
||||
qb.id,
|
||||
qb.region_id,
|
||||
coalesce(ss.subject_ids, '{}'::uuid[])
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user