feat: enforce public bank SaaS scopes

This commit is contained in:
Codex
2026-06-30 08:55:22 +08:00
parent 4f64b7aed8
commit d1cb341351
10 changed files with 576 additions and 80 deletions

View File

@@ -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,
}),
],

View File

@@ -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",

View File

@@ -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` 列表,后端校验租户地区和院校/专业引用 |

View File

@@ -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 活跃。

View File

@@ -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. 数据看板预聚合:把实时聚合升级为大租户可承载的日/周/月预聚合。

View File

@@ -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. 视频会员控制

View File

@@ -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`,前端展示“已采纳”即可。
- 已采纳公共题库可以手动同步平台后续新增/更新题目;同步会重新校验当前租户仍有授权,且只写入租户自己的题目副本。

View File

@@ -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,

View File

@@ -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(

View 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[])
)
)
)
)
)
)
);