From d1cb34135129b0e1e9b580a3789f1fae2b445b85 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 08:55:22 +0800 Subject: [PATCH] feat: enforce public bank SaaS scopes --- .../api/src/features/platform-admin/routes.ts | 45 +++- .../features/tenant-content/public-banks.ts | 152 ++++++----- docs/refactor/backend-capability-status.md | 6 +- docs/refactor/backend-handoff-roadmap.md | 6 +- docs/refactor/blueprint-coverage.md | 6 +- docs/refactor/next-development-todo.md | 5 +- docs/refactor/taro-frontend-integration.md | 2 +- scripts/api-integration-test.js | 182 ++++++++++++++ scripts/smoke-seed.js | 17 +- .../202606300007_public_bank_saas_scope.sql | 235 ++++++++++++++++++ 10 files changed, 576 insertions(+), 80 deletions(-) create mode 100644 supabase/migrations/202606300007_public_bank_saas_scope.sql diff --git a/apps/api/src/features/platform-admin/routes.ts b/apps/api/src/features/platform-admin/routes.ts index c7c86651..ac23a958 100644 --- a/apps/api/src/features/platform-admin/routes.ts +++ b/apps/api/src/features/platform-admin/routes.ts @@ -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, }), ], diff --git a/apps/api/src/features/tenant-content/public-banks.ts b/apps/api/src/features/tenant-content/public-banks.ts index 4172c9e2..9ce4befe 100644 --- a/apps/api/src/features/tenant-content/public-banks.ts +++ b/apps/api/src/features/tenant-content/public-banks.ts @@ -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( ` - 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( ` - 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", diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 2898477a..3f492edf 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -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` 列表,后端校验租户地区和院校/专业引用 | diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index da9c4427..99167fe9 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -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 活跃。 diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index d6229e1e..23533b0a 100644 --- a/docs/refactor/blueprint-coverage.md +++ b/docs/refactor/blueprint-coverage.md @@ -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. 数据看板预聚合:把实时聚合升级为大租户可承载的日/周/月预聚合。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 642ce505..e5ea8762 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -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. 视频会员控制 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 93e34f19..df835add 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -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`,前端展示“已采纳”即可。 - 已采纳公共题库可以手动同步平台后续新增/更新题目;同步会重新校验当前租户仍有授权,且只写入租户自己的题目副本。 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 028b6eb1..56aee53c 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -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, diff --git a/scripts/smoke-seed.js b/scripts/smoke-seed.js index ffbbf4ac..d0e686f8 100644 --- a/scripts/smoke-seed.js +++ b/scripts/smoke-seed.js @@ -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( diff --git a/supabase/migrations/202606300007_public_bank_saas_scope.sql b/supabase/migrations/202606300007_public_bank_saas_scope.sql new file mode 100644 index 00000000..008901bb --- /dev/null +++ b/supabase/migrations/202606300007_public_bank_saas_scope.sql @@ -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[]) + ) + ) + ) + ) + ) + ) + );