From 780feee4f80958a6279f4759bd24f5c554731759 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 07:50:02 +0800 Subject: [PATCH] feat: add platform admin permissions --- README.md | 16 ++- apps/api/src/core/auth-context.ts | 10 +- apps/api/src/core/request.ts | 21 +++- apps/api/src/features/platform-admin/index.ts | 2 + .../api/src/features/platform-admin/routes.ts | 116 ++++++++++++----- apps/taro/src/services/platformAdmin.ts | 18 +++ docs/refactor/api-structure.md | 3 +- docs/refactor/backend-capability-status.md | 4 +- docs/refactor/backend-handoff-roadmap.md | 2 +- docs/refactor/blueprint-coverage.md | 2 +- docs/refactor/frontend-handoff-index.md | 6 +- docs/refactor/implementation-status.md | 5 +- .../multitenant-auth-security-contract.md | 41 +++++- docs/refactor/next-development-todo.md | 2 +- docs/refactor/taro-frontend-integration.md | 42 +++++++ scripts/api-integration-test.js | 117 ++++++++++++++++++ scripts/import-pocketbase/src/import-json.ts | 12 +- scripts/production-readiness-check.js | 21 ++++ scripts/smoke-seed.js | 5 +- ...02606300005_platform_admin_permissions.sql | 10 ++ 20 files changed, 392 insertions(+), 63 deletions(-) create mode 100644 supabase/migrations/202606300005_platform_admin_permissions.sql diff --git a/README.md b/README.md index 03343963..1e3d5e22 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ - 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。 -- 平台后台能力:租户管理、租户详情、账务资料维护、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、用量记录、公共题库授权。 +- 平台后台能力:租户管理、租户详情、账务资料维护、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、用量记录、公共题库授权。 - 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。 - 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。 - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略和队列。 @@ -450,7 +450,7 @@ apps/api/src/features/ commerce/ 订单、支付确认、退款、激活码、优惠券规则/核销、权益、资金对账和差错工单 health/ 健康检查 learning/ 练习 session 组卷、答题、错题、收藏、学习进度、排行榜 - platform-admin/ 平台方租户、SaaS 套餐、订阅、订阅账单候选/批量生成、账单、用量 + platform-admin/ 平台方权限、租户、SaaS 套餐、订阅、订阅账单候选/批量生成、账单、用量 profile/ 学生个人中心、勋章 referral/ 销售/代理客资追踪、CRM 队列 referral/commission.ts @@ -470,6 +470,14 @@ API 身份上下文: 生产环境必须设置 `ALLOW_LEGACY_AUTH_HEADERS=false` 和 `ALLOW_PLATFORM_ADMIN_KEY=false`,前端不能再传 `x-user-id` 代表当前用户。 +平台后台权限: + +- 平台账号以后端 `platform_users.primary_role='platform_admin'` 为准,不只信 JWT claim。 +- 平台账号通过 `platform_users.platform_permissions` 控制细粒度能力,`{"*":true}` 表示超级管理员。 +- 平台后台启动后可调用 `GET /api/platform-admin/permissions` 获取 `catalog/effective`,用于隐藏不可见菜单和按钮。 +- 后端接口继续按 `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` 等权限点强制校验。 +- `x-platform-admin-key` 只允许本地兼容,生产必须关闭。 + ## 重要安全约定 - 租户公开配置和主题配置不能存放密钥;主题 token 只能是后端允许的颜色、半径、安全 CSS 变量、图标 token 和公开素材引用。 @@ -505,7 +513,7 @@ npm run audit:runtime git diff --check ``` -结果:通过。`npm run test:auth:remote-smoke` 覆盖远程 Auth/JWKS 验收脚本自身。`npm run test:rls` 覆盖 75 条运行时 RLS 断言,包含主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝。`npm run test:api` 覆盖资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览、访问记录查询、安全扫描门禁、官方账单下载任务权限和脱敏响应、异常订单运营台、人工调整凭证提交/复核/事件/报表、平台账单逾期 dry-run/催缴记录、平台审计告警查询/状态更新/越权拒绝/敏感 details 脱敏、平台审计告警通知渠道/事件查询和密钥不回显、平台催缴通知渠道/事件查询和密钥不回显、租户隔离,以及凭证审批不修改订单/支付/权益。`npm run test:worker:commerce` 覆盖支付/退款补偿、微信/支付宝官方账单下载、账单 hash 校验、导入 `provider_download` 对账批次和密钥不泄露。`npm run test:worker:platform-billing` 覆盖平台 SaaS 订阅自动计费、重复开票保护、账单明细和审计。`npm run test:worker:platform-dunning` 覆盖平台 SaaS 逾期账单标记、内部催缴记录、租户 `past_due` 状态和每日催缴幂等。`npm run test:worker:platform-dunning-notifications` 覆盖平台 SaaS 催缴外部通知入队、generic webhook 发送、幂等、防重复、联系方式掩码、签名密钥不泄露和请求 payload 脱敏。`npm run test:worker:platform-audit-alerts` 覆盖平台审计告警生成、规则匹配、幂等、防重复和告警 details 脱敏。`npm run test:worker:platform-audit-notifications` 覆盖平台审计告警外部通知入队、generic webhook 发送、幂等、防重复、签名密钥不泄露和请求 payload 脱敏。`npm run test:worker:assets` 覆盖托管资源复检、内置安全扫描、外部 HTTP scanner 通过/失败/不可用 fail-closed、扫描失败/跳过事件和异常资源自动下架。`npm run test:worker:exports` 覆盖导出 worker 生成可信资源并标记 `securityScanStatus=passed`。`npm run audit:runtime` 当前为 0 vulnerabilities;Excel 解析已从 `exceljs` 切换为 `read-excel-file`,避免生产运行时携带 `exceljs -> uuid` 的已知中危依赖。 +结果:通过。`npm run test:auth:remote-smoke` 覆盖远程 Auth/JWKS 验收脚本自身。`npm run test:rls` 覆盖 75 条运行时 RLS 断言,包含主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝。`npm run test:api` 覆盖平台细粒度权限、资源访问事件、锁定 CDN 资源拒绝、provider-managed CDN 显式放行、学生短 TTL 下载/预览、访问记录查询、安全扫描门禁、官方账单下载任务权限和脱敏响应、异常订单运营台、人工调整凭证提交/复核/事件/报表、平台账单逾期 dry-run/催缴记录、平台审计告警查询/状态更新/越权拒绝/敏感 details 脱敏、平台审计告警通知渠道/事件查询和密钥不回显、平台催缴通知渠道/事件查询和密钥不回显、租户隔离,以及凭证审批不修改订单/支付/权益。`npm run test:worker:commerce` 覆盖支付/退款补偿、微信/支付宝官方账单下载、账单 hash 校验、导入 `provider_download` 对账批次和密钥不泄露。`npm run test:worker:platform-billing` 覆盖平台 SaaS 订阅自动计费、重复开票保护、账单明细和审计。`npm run test:worker:platform-dunning` 覆盖平台 SaaS 逾期账单标记、内部催缴记录、租户 `past_due` 状态和每日催缴幂等。`npm run test:worker:platform-dunning-notifications` 覆盖平台 SaaS 催缴外部通知入队、generic webhook 发送、幂等、防重复、联系方式掩码、签名密钥不泄露和请求 payload 脱敏。`npm run test:worker:platform-audit-alerts` 覆盖平台审计告警生成、规则匹配、幂等、防重复和告警 details 脱敏。`npm run test:worker:platform-audit-notifications` 覆盖平台审计告警外部通知入队、generic webhook 发送、幂等、防重复、签名密钥不泄露和请求 payload 脱敏。`npm run test:worker:assets` 覆盖托管资源复检、内置安全扫描、外部 HTTP scanner 通过/失败/不可用 fail-closed、扫描失败/跳过事件和异常资源自动下架。`npm run test:worker:exports` 覆盖导出 worker 生成可信资源并标记 `securityScanStatus=passed`。`npm run audit:runtime` 当前为 0 vulnerabilities;Excel 解析已从 `exceljs` 切换为 `read-excel-file`,避免生产运行时携带 `exceljs -> uuid` 的已知中危依赖。 注意:`apps/taro` 是静态构建工程,线上发布 `apps/taro/dist/**`,不发布 `node_modules`。Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 提示,不能用 `npm audit fix --force` 降级到 Taro 3 破坏构建;上线验收时以 `audit:runtime`、构建产物、前端密钥检查和静态服务器配置为准,并持续跟进 Taro 官方修复。 @@ -514,7 +522,7 @@ git diff --check 优先继续补: 1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。 -2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台在线收款、审计报表增强、审计告警通知升级策略、催缴通知操作台细节和更细平台权限点,小程序兼容验证。 +2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台在线收款、审计报表增强、平台员工创建/授权 UI、审计告警通知升级策略、催缴通知操作台细节和小程序兼容验证。 3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。 4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。 5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。 diff --git a/apps/api/src/core/auth-context.ts b/apps/api/src/core/auth-context.ts index dfb3342e..7444d420 100644 --- a/apps/api/src/core/auth-context.ts +++ b/apps/api/src/core/auth-context.ts @@ -17,6 +17,7 @@ export interface SessionIdentity { sessionExpiresAt: string; authSource: 'app_session' | 'supabase_jwt'; authUserId: string | null; + platformPermissions: Record; } interface RequestAuthState { @@ -50,7 +51,8 @@ export async function findUserBySessionToken(token: string) { u.primary_role as "primaryRole", u.created_at as "createdAt", s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt", 'app_session'::text as "authSource", - u.auth_user_id as "authUserId" + u.auth_user_id as "authUserId", + u.platform_permissions as "platformPermissions" from app_private.auth_sessions s join public.platform_users u on u.id = s.user_id where s.token_hash = $1 @@ -135,7 +137,8 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex $1::text as "sessionId", $3::timestamptz as "sessionExpiresAt", 'supabase_jwt'::text as "authSource", - u.auth_user_id as "authUserId" + u.auth_user_id as "authUserId", + u.platform_permissions as "platformPermissions" from public.platform_users u left join public.tenant_memberships tm on tm.user_id = u.id and tm.status = 'active' where u.auth_user_id = $1::uuid @@ -167,7 +170,8 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex $1::text as "sessionId", $3::timestamptz as "sessionExpiresAt", 'supabase_jwt'::text as "authSource", - u.auth_user_id as "authUserId" + u.auth_user_id as "authUserId", + u.platform_permissions as "platformPermissions" from public.platform_users u join public.tenant_memberships tm on tm.user_id = u.id where u.auth_user_id = $1::uuid diff --git a/apps/api/src/core/request.ts b/apps/api/src/core/request.ts index 2bcfa9ab..c4473b99 100644 --- a/apps/api/src/core/request.ts +++ b/apps/api/src/core/request.ts @@ -147,9 +147,26 @@ export function optionalStringArray(body: JsonObject, key: string): string[] { return value.map(item => String(item)).filter(Boolean); } -export async function requirePlatformAdmin(ctx: RequestContext) { +function platformPermissionAllowed(permissions: Record | null | undefined, permission: string) { + if (!permission) return true; + if (!permissions || typeof permissions !== 'object') return false; + if (permissions['*'] === true) return true; + if (permissions[permission] === true) return true; + + const parts = permission.split(':'); + for (let i = parts.length - 1; i >= 1; i -= 1) { + const wildcard = `${parts.slice(0, i).join(':')}:*`; + if (permissions[wildcard] === true) return true; + } + return false; +} + +export async function requirePlatformAdmin(ctx: RequestContext, permission = '') { const auth = await hydrateRequestAuth(ctx); - if (auth.session?.primaryRole === 'platform_admin') return; + if (auth.session?.primaryRole === 'platform_admin') { + if (!permission || platformPermissionAllowed(auth.session.platformPermissions, permission)) return; + throw new HttpError(403, `Platform permission ${permission} is required`, 'PLATFORM_PERMISSION_REQUIRED'); + } if (auth.bearerToken) { throw new HttpError(403, 'Platform admin access is required', 'PLATFORM_ADMIN_REQUIRED'); } diff --git a/apps/api/src/features/platform-admin/index.ts b/apps/api/src/features/platform-admin/index.ts index ac5d3215..6065fe97 100644 --- a/apps/api/src/features/platform-admin/index.ts +++ b/apps/api/src/features/platform-admin/index.ts @@ -16,6 +16,7 @@ import { platformDunningNotificationChannelsRoute, platformDunningNotificationEventsRoute, platformOverviewRoute, + platformPermissionsRoute, platformPlansRoute, platformQuestionBanksRoute, processOverdueInvoicesRoute, @@ -35,6 +36,7 @@ import { } from './routes.js'; export const platformAdminRoutes: RouteDefinition[] = [ + ['GET', '/api/platform-admin/permissions', platformPermissionsRoute], ['GET', '/api/platform-admin/overview', platformOverviewRoute], ['GET', '/api/platform-admin/plans', platformPlansRoute], ['GET', '/api/platform-admin/question-banks', platformQuestionBanksRoute], diff --git a/apps/api/src/features/platform-admin/routes.ts b/apps/api/src/features/platform-admin/routes.ts index cbc779fb..55911a2c 100644 --- a/apps/api/src/features/platform-admin/routes.ts +++ b/apps/api/src/features/platform-admin/routes.ts @@ -58,6 +58,28 @@ const PLATFORM_AUDIT_SEVERITIES = new Set(['low', 'medium', 'high', 'critical']) const PLATFORM_DUNNING_REMINDER_TYPES = new Set(['due_soon', 'overdue', 'final_notice', 'manual']); const PLATFORM_DUNNING_REMINDER_CHANNELS = new Set(['manual', 'internal', 'sms', 'email', 'wechat', 'crm']); +const PLATFORM_PERMISSION_CATALOG = [ + { key: 'platform:overview:read', group: 'overview', label: '平台概览' }, + { key: 'platform:tenant:read', group: 'tenant', label: '查看租户' }, + { key: 'platform:tenant:write', group: 'tenant', label: '创建/编辑租户' }, + { key: 'platform:tenant:status', group: 'tenant', label: '变更租户状态' }, + { key: 'platform:tenant:billing_profile', group: 'tenant', label: '维护租户账务资料' }, + { key: 'platform:plan:read', group: 'billing', label: '查看 SaaS 套餐' }, + { key: 'platform:billing:read', group: 'billing', label: '查看平台账务' }, + { key: 'platform:billing:write', group: 'billing', label: '创建订阅/账单' }, + { key: 'platform:billing:payment', group: 'billing', label: '确认服务费收款' }, + { key: 'platform:billing:dunning', group: 'billing', label: '处理逾期催缴' }, + { key: 'platform:billing:notification', group: 'billing', label: '维护催缴通知' }, + { key: 'platform:usage:read', group: 'usage', label: '查看租户用量' }, + { key: 'platform:usage:write', group: 'usage', label: '记录租户用量' }, + { key: 'platform:audit:read', group: 'audit', label: '查看平台审计' }, + { key: 'platform:audit:export', group: 'audit', label: '导出平台审计' }, + { key: 'platform:audit:alert', group: 'audit', label: '处理平台审计告警' }, + { key: 'platform:audit:notification', group: 'audit', label: '维护审计告警通知' }, + { key: 'platform:question_bank:read', group: 'question_bank', label: '查看公共题库' }, + { key: 'platform:question_bank:grant', group: 'question_bank', label: '授权公共题库' }, +] as const; + function csvEscape(value: unknown) { if (value === null || value === undefined) return ''; const text = typeof value === 'object' ? JSON.stringify(value) : String(value); @@ -476,8 +498,38 @@ function grantStatusFrom(value: string) { return status; } -export async function platformOverviewRoute(ctx: RequestContext) { +function platformPermissionAllowed(permissions: Record, permission: string) { + if (permissions['*'] === true) return true; + if (permissions[permission] === true) return true; + const parts = permission.split(':'); + for (let i = parts.length - 1; i >= 1; i -= 1) { + if (permissions[`${parts.slice(0, i).join(':')}:*`] === true) return true; + } + return false; +} + +export async function platformPermissionsRoute(ctx: RequestContext) { await requirePlatformAdmin(ctx); + const session = currentSessionFromContext(ctx); + const permissions = session?.platformPermissions && typeof session.platformPermissions === 'object' + ? session.platformPermissions + : { '*': true }; + + return { + item: { + userId: session?.id || null, + primaryRole: session?.primaryRole || 'platform_admin_key', + permissions, + effective: Object.fromEntries( + PLATFORM_PERMISSION_CATALOG.map(item => [item.key, platformPermissionAllowed(permissions, item.key)]), + ), + catalog: PLATFORM_PERMISSION_CATALOG, + }, + }; +} + +export async function platformOverviewRoute(ctx: RequestContext) { + await requirePlatformAdmin(ctx, 'platform:overview:read'); const [tenantStats, invoiceStats, subscriptionStats, usageStats] = await Promise.all([ queryOne<{ @@ -554,7 +606,7 @@ export async function platformOverviewRoute(ctx: RequestContext) { } export async function platformPlansRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:plan:read'); const includeArchived = listQuery(ctx, 'includeArchived') === 'true'; const items = await query( @@ -574,7 +626,7 @@ export async function platformPlansRoute(ctx: RequestContext) { } export async function platformQuestionBanksRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:question_bank:read'); const q = listQuery(ctx, 'q'); const regionId = listQuery(ctx, 'regionId'); @@ -617,7 +669,7 @@ export async function platformQuestionBanksRoute(ctx: RequestContext) { } export async function questionBankGrantsRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:question_bank:read'); const questionBankId = listQuery(ctx, 'questionBankId'); const status = listQuery(ctx, 'status'); @@ -649,7 +701,7 @@ export async function questionBankGrantsRoute(ctx: RequestContext) { } export async function upsertQuestionBankGrantRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:question_bank:grant'); const body = await readJsonBody(ctx); const questionBankId = requiredString(body, 'sourceQuestionBankId'); @@ -777,7 +829,7 @@ export async function upsertQuestionBankGrantRoute(ctx: RequestContext) { } export async function tenantsRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:tenant:read'); const status = listQuery(ctx, 'status'); const billingStatus = listQuery(ctx, 'billingStatus'); @@ -825,7 +877,7 @@ export async function tenantsRoute(ctx: RequestContext) { } export async function tenantDetailRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:tenant:read'); const tenantId = ctx.url.searchParams.get('tenantId') || ''; if (!tenantId) throw new HttpError(400, 'tenantId is required', 'TENANT_ID_REQUIRED'); @@ -907,7 +959,7 @@ export async function tenantDetailRoute(ctx: RequestContext) { } export async function platformAuditLogsRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:audit:read'); const auditQuery = auditLogQueryParams(ctx, 100, 500); const items = await loadPlatformAuditLogs(auditQuery); @@ -916,7 +968,7 @@ export async function platformAuditLogsRoute(ctx: RequestContext) { } export async function platformAuditLogsExportRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:audit:export'); const format = auditExportFormat(listQuery(ctx, 'format')); const auditQuery = auditLogQueryParams(ctx, 1000, 5000); @@ -986,7 +1038,7 @@ export async function platformAuditLogsExportRoute(ctx: RequestContext) { } export async function platformAuditAlertRulesRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:audit:alert'); const enabled = listQuery(ctx, 'enabled'); if (enabled && !['true', 'false'].includes(enabled)) { @@ -1010,7 +1062,7 @@ export async function platformAuditAlertRulesRoute(ctx: RequestContext) { } export async function platformAuditAlertsRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:audit:alert'); const tenantId = listQuery(ctx, 'tenantId'); const status = listQuery(ctx, 'status'); @@ -1077,7 +1129,7 @@ export async function platformAuditAlertsRoute(ctx: RequestContext) { } export async function updatePlatformAuditAlertStatusRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:audit:alert'); const body = await readJsonBody(ctx); const alertId = requiredString(body, 'alertId'); @@ -1127,7 +1179,7 @@ export async function updatePlatformAuditAlertStatusRoute(ctx: RequestContext) { } export async function platformAuditNotificationChannelsRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:audit:notification'); const enabled = listQuery(ctx, 'enabled'); const provider = listQuery(ctx, 'provider'); @@ -1162,7 +1214,7 @@ export async function platformAuditNotificationChannelsRoute(ctx: RequestContext } export async function upsertPlatformAuditNotificationChannelRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:audit:notification'); const body = await readJsonBody(ctx); const channelCode = platformAuditNotificationChannelCode(requiredString(body, 'channelCode')); @@ -1270,7 +1322,7 @@ export async function upsertPlatformAuditNotificationChannelRoute(ctx: RequestCo } export async function platformAuditNotificationEventsRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:audit:notification'); const channelId = listQuery(ctx, 'channelId'); const alertId = listQuery(ctx, 'alertId'); @@ -1329,7 +1381,7 @@ export async function platformAuditNotificationEventsRoute(ctx: RequestContext) } export async function platformDunningNotificationChannelsRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:billing:notification'); const enabled = listQuery(ctx, 'enabled'); const provider = listQuery(ctx, 'provider'); @@ -1362,7 +1414,7 @@ export async function platformDunningNotificationChannelsRoute(ctx: RequestConte } export async function upsertPlatformDunningNotificationChannelRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:billing:notification'); const body = await readJsonBody(ctx); const channelCode = platformAuditNotificationChannelCode(requiredString(body, 'channelCode')); @@ -1471,7 +1523,7 @@ export async function upsertPlatformDunningNotificationChannelRoute(ctx: Request } export async function platformDunningNotificationEventsRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:billing:notification'); const channelId = listQuery(ctx, 'channelId'); const reminderId = listQuery(ctx, 'reminderId'); @@ -1540,7 +1592,7 @@ export async function platformDunningNotificationEventsRoute(ctx: RequestContext } export async function createTenantRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:tenant:write'); const body = await readJsonBody(ctx); const slug = normalizeSlug(requiredString(body, 'slug')); @@ -1679,7 +1731,7 @@ export async function createTenantRoute(ctx: RequestContext) { } export async function updateTenantStatusRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:tenant:status'); const body = await readJsonBody(ctx); const tenantId = requiredString(body, 'tenantId'); @@ -1715,7 +1767,7 @@ export async function updateTenantStatusRoute(ctx: RequestContext) { } export async function upsertBillingProfileRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:tenant:billing_profile'); const body = await readJsonBody(ctx); const tenantId = requiredString(body, 'tenantId'); @@ -1781,7 +1833,7 @@ export async function upsertBillingProfileRoute(ctx: RequestContext) { } export async function createSubscriptionRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:billing:write'); const body = await readJsonBody(ctx); const tenantId = requiredString(body, 'tenantId'); @@ -1844,7 +1896,7 @@ export async function createSubscriptionRoute(ctx: RequestContext) { } export async function tenantInvoicesRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:billing:read'); const tenantId = ctx.url.searchParams.get('tenantId') || ''; const status = listQuery(ctx, 'status'); @@ -1966,7 +2018,7 @@ async function createInvoiceRecord(input: CreateInvoiceInput) { } export async function createInvoiceRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:billing:write'); const body = await readJsonBody(ctx); const tenantId = requiredString(body, 'tenantId'); @@ -1992,7 +2044,7 @@ export async function createInvoiceRoute(ctx: RequestContext) { } export async function confirmInvoicePaymentRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:billing:payment'); const body = await readJsonBody(ctx); const tenantId = requiredString(body, 'tenantId'); @@ -2088,7 +2140,7 @@ export async function confirmInvoicePaymentRoute(ctx: RequestContext) { } export async function processOverdueInvoicesRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:billing:dunning'); const body = await readJsonBody(ctx); const dryRun = booleanFrom(body.dryRun, false); @@ -2124,7 +2176,7 @@ export async function processOverdueInvoicesRoute(ctx: RequestContext) { } export async function invoiceRemindersRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:billing:dunning'); const tenantId = ctx.url.searchParams.get('tenantId') || ''; const invoiceId = ctx.url.searchParams.get('invoiceId') || ''; @@ -2160,7 +2212,7 @@ export async function invoiceRemindersRoute(ctx: RequestContext) { } export async function recordUsageRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:usage:write'); const body = await readJsonBody(ctx); const tenantId = requiredString(body, 'tenantId'); @@ -2187,7 +2239,7 @@ export async function recordUsageRoute(ctx: RequestContext) { } export async function tenantUsageRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:usage:read'); const tenantId = ctx.url.searchParams.get('tenantId') || ''; const limit = intParam(ctx, 'limit', 100, 500); @@ -2274,7 +2326,7 @@ async function subscriptionInvoiceCandidateQuery(params: { } export async function subscriptionInvoiceCandidatesRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:billing:read'); const tenantIds = optionalUuidList(ctx.url.searchParams.get('tenantIds'), 'tenantIds'); const subscriptionIds = optionalUuidList(ctx.url.searchParams.get('subscriptionIds'), 'subscriptionIds'); @@ -2292,7 +2344,7 @@ export async function subscriptionInvoiceCandidatesRoute(ctx: RequestContext) { } export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:billing:write'); const body = await readJsonBody(ctx); const tenantId = requiredString(body, 'tenantId'); @@ -2382,7 +2434,7 @@ export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestConte } export async function createTenantInvoicesBatchFromSubscriptionsRoute(ctx: RequestContext) { - await requirePlatformAdmin(ctx); + await requirePlatformAdmin(ctx, 'platform:billing:write'); const body = await readJsonBody(ctx); const tenantIds = optionalUuidList(body.tenantIds, 'tenantIds'); diff --git a/apps/taro/src/services/platformAdmin.ts b/apps/taro/src/services/platformAdmin.ts index 465586a0..703c2b5b 100644 --- a/apps/taro/src/services/platformAdmin.ts +++ b/apps/taro/src/services/platformAdmin.ts @@ -24,6 +24,20 @@ export interface PlatformOverview { }; } +export interface PlatformPermissionCatalogItem { + key: string; + group?: string | null; + label?: string | null; +} + +export interface PlatformPermissionSummary { + userId?: string | null; + primaryRole?: string | null; + permissions?: Record; + effective?: Record; + catalog?: PlatformPermissionCatalogItem[]; +} + export interface PlatformSaasPlan { id: string; code: string; @@ -468,6 +482,10 @@ export async function loadPlatformOverview() { return apiRequest<{ item?: PlatformOverview }>('/api/platform-admin/overview', { tenantId: null }); } +export async function loadPlatformPermissions() { + return apiRequest<{ item?: PlatformPermissionSummary }>('/api/platform-admin/permissions', { tenantId: null }); +} + export async function loadPlatformPlans(includeArchived = false) { return apiRequest<{ items?: PlatformSaasPlan[] }>('/api/platform-admin/plans', { query: { includeArchived }, diff --git a/docs/refactor/api-structure.md b/docs/refactor/api-structure.md index f520a4d0..740cd495 100644 --- a/docs/refactor/api-structure.md +++ b/docs/refactor/api-structure.md @@ -22,7 +22,7 @@ apps/api/src/ referral/ 销售/代理客资追踪、首绑保护、团队关系、CRM 队列 storage/ 对象存储签名 provider video/ 题目视频列表、搜索、SVIP/次数校验和签名播放 - platform-admin/ 平台方 SaaS 租户、订阅、订阅账单候选/批量生成、账单、使用量 + platform-admin/ 平台方权限、SaaS 租户、订阅、订阅账单候选/批量生成、账单、使用量 tenant-admin/ 租户品牌、域名、公开设置、登录/商户配置、成员权限、活动/兑换码运营 tenant-content/ 租户后台内容维护:入口、分类树、集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、批量导入 ``` @@ -65,6 +65,7 @@ types.ts 仅本领域使用的类型 - 新接口优先使用 `Authorization: Bearer `;后端通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务身份。 - 迁移期仍支持 `Authorization: Bearer tk_*` session;session 明文只返回客户端,数据库只保存 hash。 - `x-user-id` 和 `x-platform-admin-key` 只允许在非生产兼容模式使用;生产必须关闭 `ALLOW_LEGACY_AUTH_HEADERS` 和 `ALLOW_PLATFORM_ADMIN_KEY`。 +- `platform-admin` 路由必须用 `requirePlatformAdmin(ctx, '')` 校验平台权限;前端可用 `GET /api/platform-admin/permissions` 读取权限目录和当前账号 `effective` 能力,但不能把菜单隐藏当成安全边界。 - `platform-admin` 管平台与合作商之间的 SaaS 账务,`tenant-admin` 管合作商自己的品牌、域名、公开配置、登录/商户配置、活动和兑换码,`tenant-content` 管合作商自己的题库和学习内容维护。 - `tenant-admin` 的敏感配置必须拆分:公开字段进入 `config_public`,商户密钥、短信密钥、OAuth app secret 进入 `app_private.tenant_secrets` 或生产 KMS/Vault;对前端只返回 `secretRef` 和掩码状态。 - `tenant-admin` 权限由 `tenant_memberships.role` 的默认权限和 `permissions` JSON 覆盖共同决定;后端接口必须校验具体权限点,不能只依赖前端菜单隐藏。 diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 9b180f3f..3c0680ec 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -41,7 +41,7 @@ | 微信小程序登录 | 可联调 | `/api/auth/oauth/wechat-miniapp` 已接 `code2Session`、openid/unionid 身份、session 签发和登录审计 | | 手机号绑定/换绑 | 可联调 | `/api/auth/phone/bind` 使用 `bind_phone` 短信验证码,后端校验当前登录态、手机号唯一性、移除旧手机号 identity,并撤销其它迁移期 session | | 微信网页/QQ OAuth | 可联调 | `/api/auth/oauth/wechat` 已完成微信网页登录 code 换 token、userinfo、unionid 合并、session 签发和审计;`/api/auth/oauth/qq` 已完成 code/token/openid/userinfo 主链路;生产前需真实开放平台账号和回调域名联调 | -| 平台管理员鉴权 | 可联调 | 已支持平台管理员 Supabase JWT;`x-platform-admin-key` 仅作本地/迁移期兼容且可通过配置禁用 | +| 平台管理员鉴权 | 可联调 | 已支持平台管理员 Supabase JWT;平台账号以后端 `platform_users.primary_role='platform_admin'` 和 `platform_permissions` 为准;`GET /api/platform-admin/permissions` 返回权限目录和当前账号 `effective` 能力,平台路由按 `platform:tenant:*`、`platform:billing:*`、`platform:audit:*`、`platform:question_bank:*` 等权限点强制校验;`x-platform-admin-key` 仅作本地/迁移期兼容且可通过配置禁用 | | 租户角色权限 | 可联调 | `tenant_memberships.role + permissions + role_template_id`,接口有权限点校验 | | 自定义角色模板 | 可联调 | `tenant_role_templates` + `/api/tenant-admin/role-templates`,支持权限、菜单、模块、字段、数据范围配置;Taro 租户设置页已接创建、编辑、停用、权限点、菜单、模块、字段和基础数据范围配置第一版 | | 班级/教师/学生范围权限 | 可联调 | `tenant_classes`、`tenant_class_members` + `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`;教师默认只看自己负责班级,字段权限可脱敏学生手机号 | @@ -145,7 +145,7 @@ | 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`,支持班级范围权限和审计 | | 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 | | 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 | -| 平台租户/详情/账务资料/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志查询、CSV/JSON 审计导出、平台审计告警规则查询、告警列表、确认/解决/忽略、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款、逾期标记、内部催缴台账、催缴外部通知渠道和发送事件、用量;审计导出、告警响应和通知事件都会对 `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 会脱敏;创建租户、状态变更、账务资料维护、订阅批量开票、自动开票、逾期催缴、手工收款确认、审计导出、告警状态更新、通知渠道变更和催缴通知渠道变更会写入审计 | +| 平台租户/详情/账务资料/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持当前平台账号权限目录、租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志查询、CSV/JSON 审计导出、平台审计告警规则查询、告警列表、确认/解决/忽略、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款、逾期标记、内部催缴台账、催缴外部通知渠道和发送事件、用量;平台 API 已拆分 `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 | diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 54fdf035..715caef7 100644 --- a/docs/refactor/backend-handoff-roadmap.md +++ b/docs/refactor/backend-handoff-roadmap.md @@ -21,7 +21,7 @@ | 模块 | 当前状态 | 已经具备 | 上线前还要补 | | --- | --- | --- | --- | | 多租户底座 | 可联调 | 租户、域名、品牌、设置、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、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 平台员工账号创建/授权 UI 与管理 API、平台在线收款、平台审计告警升级策略和更完整运营消息 | | 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 | | 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 | | 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 | diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index 14f24d19..725611bc 100644 --- a/docs/refactor/blueprint-coverage.md +++ b/docs/refactor/blueprint-coverage.md @@ -15,7 +15,7 @@ | 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 | | --- | --- | --- | --- | -| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费收款、逾期标记、内部催缴台账、催缴外部通知、用量记录、公共题库披露策略第一版 | 地区/全国套餐权限细化、平台侧主题模板库、平台在线收款、平台审计报表增强和更细平台权限点 | +| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台账号细粒度权限点、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费收款、逾期标记、内部催缴台账、催缴外部通知、用量记录、公共题库披露策略第一版 | 地区/全国套餐权限细化、平台侧主题模板库、平台在线收款、平台审计报表增强和平台员工创建/授权 UI | | 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 | | 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student,权限矩阵,成员启停,角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 | | 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker、同步通知和冲突查询 API | 字段映射 UI、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 | diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 4f540ce4..8749576d 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -31,7 +31,7 @@ - `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。 - 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。 -- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权;租户管理页已接租户详情、账务资料编辑和最近平台审计,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV,并可查看开放审计告警、确认或解决告警,也能查看审计告警外部通知渠道、催缴外部通知渠道和最近发送事件摘要;账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、逾期预览、内部催缴生成和催缴记录查看。 +- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权;启动时应先接 `GET /api/platform-admin/permissions` 获取 `effective` 权限用于菜单和按钮可见性;租户管理页已接租户详情、账务资料编辑和最近平台审计,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV,并可查看开放审计告警、确认或解决告警,也能查看审计告警外部通知渠道、催缴外部通知渠道和最近发送事件摘要;账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、逾期预览、内部催缴生成和催缴记录查看。 - 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验、背单词更细统计和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习、商城收银台、订单详情和售后入口已经有第一版页面。 - 可以按新后端主模型接入内容导航: - `content_entries` @@ -106,9 +106,9 @@ | 页面 | 文件 | 已接接口 | | --- | --- | --- | -| 工作台 | `apps/taro/src/pages/platform-admin/workbench/index.tsx` | `platform-admin/overview`、`tenants`、`invoices`、`question-banks`、`question-bank-grants`、`audit-logs`、`audit-logs/export`、`audit-alerts`、`audit-alerts/status`、`audit-notification-channels/events`、`dunning-notification-channels/events` | +| 工作台 | `apps/taro/src/pages/platform-admin/workbench/index.tsx` | `platform-admin/permissions`、`platform-admin/overview`、`tenants`、`invoices`、`question-banks`、`question-bank-grants`、`audit-logs`、`audit-logs/export`、`audit-alerts`、`audit-alerts/status`、`audit-notification-channels/events`、`dunning-notification-channels/events` | | 租户管理 | `apps/taro/src/pages/platform-admin/tenants/index.tsx` | `platform-admin/tenants`、`POST tenants`、`tenants/detail`、`PATCH tenants/status`、`PUT tenants/billing-profile`、`audit-logs` | | 账务中心 | `apps/taro/src/pages/platform-admin/billing/index.tsx` | `platform-admin/plans`、`invoices`、`invoices/subscription-candidates`、`invoices/from-subscription`、`invoices/from-subscriptions-batch`、`invoices/payments/manual-confirm`、`usage`、`subscriptions`、`POST usage` | | 公共题库 | `apps/taro/src/pages/platform-admin/question-banks/index.tsx` | `platform-admin/question-banks`、`question-bank-grants`、`PUT question-bank-grants` | -当前平台后台已经具备第一批写操作台:创建租户、租户详情查看、状态变更、账务资料维护、最近平台审计查询、最近平台审计 CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件摘要、催缴外部通知渠道/事件摘要、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、人工收款确认、逾期预览、内部催缴生成、催缴记录查看、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限、重复开票保护和审计。平台审计导出只开放给平台管理员,后端会对导出 `details` 中的 token/secret/password/key 等敏感字段脱敏,并返回 `contentBase64 + sha256`,H5 可直接下载,小程序端建议先展示“已生成,需在 H5 管理台下载”。平台审计告警由 `platform-audit-alerts` worker 从高风险平台审计动作生成,外部通知由 `platform-audit-notifications` worker 根据平台渠道配置发送;平台催缴外部通知由 `platform-dunning-notifications` worker 根据 `tenant_invoice_reminders` 和平台渠道配置发送。前端只能调用告警查询、状态更新、通知渠道和发送事件 API,不要直接写 `platform_audit_alerts`、`platform_audit_notification_channels`、`platform_audit_notification_events`、`platform_dunning_notification_channels` 或 `platform_dunning_notification_events` 表。后端会对告警 `details`、通知 payload 和催缴 payload 递归脱敏,渠道 API 只回显 `secretRef` 和 webhook host/path。下一批继续补租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节、平台在线收款和更细平台权限点。 +当前平台后台已经具备第一批写操作台:创建租户、租户详情查看、状态变更、账务资料维护、最近平台审计查询、最近平台审计 CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件摘要、催缴外部通知渠道/事件摘要、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、人工收款确认、逾期预览、内部催缴生成、催缴记录查看、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限、重复开票保护和审计。平台后台的菜单和按钮必须用 `platform-admin/permissions` 返回的 `effective` 做可见性控制,但安全边界仍以后端 `PLATFORM_PERMISSION_REQUIRED` 为准。平台审计导出只开放给具备 `platform:audit:export` 的平台账号,后端会对导出 `details` 中的 token/secret/password/key 等敏感字段脱敏,并返回 `contentBase64 + sha256`,H5 可直接下载,小程序端建议先展示“已生成,需在 H5 管理台下载”。平台审计告警由 `platform-audit-alerts` worker 从高风险平台审计动作生成,外部通知由 `platform-audit-notifications` worker 根据平台渠道配置发送;平台催缴外部通知由 `platform-dunning-notifications` worker 根据 `tenant_invoice_reminders` 和平台渠道配置发送。前端只能调用告警查询、状态更新、通知渠道和发送事件 API,不要直接写 `platform_audit_alerts`、`platform_audit_notification_channels`、`platform_audit_notification_events`、`platform_dunning_notification_channels` 或 `platform_dunning_notification_events` 表。后端会对告警 `details`、通知 payload 和催缴 payload 递归脱敏,渠道 API 只回显 `secretRef` 和 webhook host/path。下一批继续补租户基础资料编辑增强、平台员工创建/授权 UI 与管理 API、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 63ddcbad..2f98d85e 100644 --- a/docs/refactor/implementation-status.md +++ b/docs/refactor/implementation-status.md @@ -4,7 +4,7 @@ ## 当前结论 -当前重构已经完成了 Supabase/PostgreSQL 多租户底座、核心业务表、PocketBase 数据导入器雏形、学生端核心 API、租户后台 API、平台后台 SaaS 账务 API、内容资产/题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入基础闭环、异步导入 worker、题库导出基础、PDF/Word/每日一练 ZIP 导出 worker、题库入口/任意深度分类/题目集合/练习蓝图/组卷快照基础闭环,以及本地 Docker/API 构建验证。 +当前重构已经完成了 Supabase/PostgreSQL 多租户底座、核心业务表、PocketBase 数据导入器雏形、学生端核心 API、租户后台 API、平台后台 SaaS 账务 API、平台账号细粒度权限地基、内容资产/题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入基础闭环、异步导入 worker、题库导出基础、PDF/Word/每日一练 ZIP 导出 worker、题库入口/任意深度分类/题目集合/练习蓝图/组卷快照基础闭环,以及本地 Docker/API 构建验证。 但这还不是完整商用交付状态,也不能说旧项目核心功能已经全部重构完成。现在更准确的状态是:后端商用架构骨架已经立住,核心业务正在按模块补齐。部分功能已经有可调用 API,部分功能只有数据模型和导入映射,部分功能还没有前端/自动化测试闭环。 @@ -37,7 +37,7 @@ | 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务自动发放、积分任务领取、积分兑换、优惠券兑换履约和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换和用户通知查看第一版;连续签到奖励配置、练习/单词/模考触发勋章、营销自动化、积分风控报表、外部订阅消息/短信和更完整活动效果看板继续补 | | 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 | | 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用;Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI | -| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量、审计日志、催缴台账和催缴通知事件 | 不适用 | 租户管理、租户详情、账务资料维护、平台审计日志、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款确认、逾期标记、内部催缴记录、催缴外部通知渠道/事件、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台租户创建、详情、账务资料更新、状态变更、审计查询、订阅批量开票、重复保护、逾期 dry-run/处理/提醒查询、催缴通知渠道/事件脱敏、非法输入拒绝和学生越权拒绝;`npm run test:worker:platform-billing` 覆盖自动计费幂等和审计,`npm run test:worker:platform-dunning` 覆盖逾期催缴幂等和审计,`npm run test:worker:platform-dunning-notifications` 覆盖催缴外部通知幂等、联系方式掩码和密钥不泄露 | 平台收费和租户运营链路骨架可用,平台在线收款和更完整平台审计报表待补 | +| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量、审计日志、催缴台账、催缴通知事件和平台权限字段 | 不适用 | 租户管理、租户详情、账务资料维护、平台账号权限目录、平台路由细粒度权限强校验、平台审计日志、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款确认、逾期标记、内部催缴记录、催缴外部通知渠道/事件、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台细粒度权限、平台租户创建、详情、账务资料更新、状态变更、审计查询、订阅批量开票、重复保护、逾期 dry-run/处理/提醒查询、催缴通知渠道/事件脱敏、非法输入拒绝和学生越权拒绝;`npm run test:worker:platform-billing` 覆盖自动计费幂等和审计,`npm run test:worker:platform-dunning` 覆盖逾期催缴幂等和审计,`npm run test:worker:platform-dunning-notifications` 覆盖催缴外部通知幂等、联系方式掩码和密钥不泄露 | 平台收费和租户运营链路骨架可用,平台员工创建/授权 UI、平台在线收款和更完整平台审计报表待补 | | 登录认证 | 已建短信验证码、会话、OAuth provider 配置表,并支持 `auth_user_id` 映射 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、Supabase JWT 验签映射、微信小程序登录主链路、微信网页登录、QQ 登录、手机号绑定/换绑已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信/OAuth 生产账号和回调域名联调待补 | | 数据导入 | 已建立 importer、risk report、dry-run report、validate | 已覆盖多类旧集合 | 命令行 dry-run/导入/校验 | `pb:import:dry-run`、`pb:import:validate`、`test:pb:dry-run` 覆盖 strict warning 和关系断裂门禁 | 基础工具和真实迁移 runbook 可用,需拿真实完整数据执行多轮 dry-run、导入回归和抽样验收 | | 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 | @@ -273,6 +273,7 @@ tenant-admin: GET /api/tenant-admin/audit-logs platform-admin: + GET /api/platform-admin/permissions GET /api/platform-admin/overview GET /api/platform-admin/plans GET /api/platform-admin/tenants diff --git a/docs/refactor/multitenant-auth-security-contract.md b/docs/refactor/multitenant-auth-security-contract.md index 177f2d28..892e0193 100644 --- a/docs/refactor/multitenant-auth-security-contract.md +++ b/docs/refactor/multitenant-auth-security-contract.md @@ -1,6 +1,6 @@ # 多租户与鉴权安全契约 -更新时间:2026-06-29 +更新时间:2026-06-30 这个系统后续要卖给同行作为题库 SaaS,因此租户隔离、鉴权、资源权限和审计是商用红线。前端可以先按迁移期接口联调,也可以按 Supabase 官方推荐使用 publishable key + RLS 的客户端能力管理 Auth/session,但正式上云验收前必须完成本文件的 P0 项。 @@ -8,7 +8,7 @@ | 边界 | 规则 | | --- | --- | -| 平台超级管理员 | 可管理全部租户、SaaS 套餐、订阅、账单、用量、公共题库 | +| 平台超级管理员 | 按 `platform_permissions` 管理全部租户、SaaS 套餐、订阅、账单、用量、公共题库和平台审计 | | 租户管理员 | 只能管理自己租户的品牌、域名、成员、内容、营销、订单和 CRM | | 租户成员 | 按角色和 permissions 访问,例如运营、教师、销售、代理 | | 学生用户 | 只能访问自己所在租户下被授权的内容和自己的学习数据 | @@ -62,7 +62,8 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小 3. 平台管理员鉴权 - `x-platform-admin-key` 已可通过 `ALLOW_PLATFORM_ADMIN_KEY=false` 禁用。 - 已支持平台管理员 Supabase JWT,且以后端 `platform_users.primary_role='platform_admin'` 为准,不只信 JWT claim。 - - 生产前继续补平台后台关键操作审计报表和更细权限点。 + - 平台管理员已支持 `platform_users.platform_permissions` 细粒度权限,`{"*":true}` 为超级管理员;接口按 `platform:tenant:*`、`platform:billing:*`、`platform:audit:*`、`platform:question_bank:*` 等权限点强制校验。 + - 生产前继续补平台后台关键操作审计报表和平台员工创建/授权 UI。 4. 生产配置 fail-fast - `NODE_ENV=production` 时禁止默认 `AUTH_CODE_PEPPER`。 @@ -125,7 +126,7 @@ requireTenantPermission(auth, ':') 平台类接口必须满足: ```text -requirePlatformAdmin(auth) +requirePlatformAdmin(auth, '') ``` 资源下载必须满足: @@ -151,6 +152,38 @@ provider event id 幂等 ## 权限点现状 +平台权限点: + +| 权限点 | 用途 | +| --- | --- | +| `platform:overview:read` | 查看平台总览 | +| `platform:tenant:read` | 查看租户列表和租户详情 | +| `platform:tenant:write` | 创建/编辑租户 | +| `platform:tenant:status` | 变更租户状态 | +| `platform:tenant:billing_profile` | 维护租户账务资料 | +| `platform:plan:read` | 查看 SaaS 套餐 | +| `platform:billing:read` | 查看平台账单、订阅候选和催缴记录 | +| `platform:billing:write` | 创建订阅和服务费账单 | +| `platform:billing:payment` | 确认平台服务费收款 | +| `platform:billing:dunning` | 执行逾期处理和内部催缴 | +| `platform:billing:notification` | 配置/查看催缴外部通知 | +| `platform:usage:read` | 查看租户用量 | +| `platform:usage:write` | 记录租户用量 | +| `platform:audit:read` | 查看平台审计 | +| `platform:audit:export` | 导出平台审计 | +| `platform:audit:alert` | 查看/处理平台审计告警 | +| `platform:audit:notification` | 配置/查看审计告警外部通知 | +| `platform:question_bank:read` | 查看平台公共题库 | +| `platform:question_bank:grant` | 授权平台公共题库 | + +前端平台后台启动后可调用: + +```text +GET /api/platform-admin/permissions +``` + +响应中的 `effective` 只用于菜单和按钮可见性;真正权限仍由后端每个接口强制校验。生产 readiness 会阻断 `primary_role='platform_admin'` 但 `platform_permissions` 为空的账号,避免平台账号上线后权限不明确。 + 当前默认角色: | 角色 | 默认权限 | diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 086361dc..c59d8c63 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -243,7 +243,7 @@ 1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。 2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、站内消息筛选/已读/归档、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、独立消息中心增强、背单词更细统计、小程序支付容器、分享场景和状态管理。 -3. 补平台后台增强:租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节、平台在线收款和更细平台权限点。 +3. 补平台后台增强:租户基础资料编辑增强、平台员工创建/授权 UI 与管理 API、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。 4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。 5. 导出现有 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 做 production dry-run、导入演练、校验和抽样验收。 6. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。 diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 61ba7bf6..93e34f19 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -283,6 +283,7 @@ tenant::theme | 公共题库采纳/同步 | `GET /api/tenant-content/public-question-banks`、`POST /api/tenant-content/public-question-banks/adopt`、`POST /api/tenant-content/public-question-banks/sync`、`GET /api/tenant-content/public-question-banks/conflicts?adoptionId=...`、`POST /api/tenant-content/public-question-banks/conflicts/resolve`、`POST /api/tenant-content/public-question-banks/conflicts/resolve-batch` | | 题库导出 | `POST /api/tenant-content/exports/questions`、`GET /api/tenant-content/exports/jobs` | | 资料/视频运营审计 | `GET /api/tenant-content/media-analytics/summary`、`asset-events`、`video-events` | +| 平台权限目录 | `GET /api/platform-admin/permissions` | ## 资料、PDF 和视频资源契约 @@ -2600,6 +2601,47 @@ format csv | json 平台审计告警属于超级管理员后台能力。内部告警由 `apps/worker --job platform-audit-alerts` 根据 `platform_audit_alert_rules` 从平台审计日志生成,外部通知由 `apps/worker --job platform-audit-notifications` 根据 `platform_audit_notification_channels` 入队并发送到 generic、钉钉、飞书或企业微信 webhook。前端不要直接读写 Supabase 表。 +## 平台后台权限 + +平台后台不再假设所有 `platform_admin` 都是全权限。Taro 平台后台启动后应先调用: + +```text +GET /api/platform-admin/permissions +``` + +响应结构: + +```json +{ + "item": { + "primaryRole": "platform_admin", + "permissions": { + "platform:tenant:read": true, + "platform:billing:read": true + }, + "effective": { + "platform:tenant:read": true, + "platform:tenant:write": false + }, + "catalog": [ + { + "key": "platform:tenant:read", + "group": "tenant", + "label": "查看租户" + } + ] + } +} +``` + +前端处理规则: + +- `effective` 只用于隐藏菜单、按钮和表单,不是安全边界。 +- 真实权限由后端在每个 `/api/platform-admin/*` 接口强制校验;越权会返回 `PLATFORM_PERMISSION_REQUIRED`。 +- 平台菜单推荐映射:租户中心用 `platform:tenant:read`,创建租户用 `platform:tenant:write`,状态变更用 `platform:tenant:status`,账务资料用 `platform:tenant:billing_profile`,账务中心用 `platform:billing:read/write/payment/dunning`,审计中心用 `platform:audit:read/export/alert/notification`,公共题库用 `platform:question_bank:read/grant`。 +- `permissions` 里可能出现 `{"*":true}` 或 `platform:billing:*` 这类通配权限,前端只消费后端返回的 `effective`,不要自己重新实现权限匹配规则。 +- 平台后台 H5 不允许保存 `x-platform-admin-key`;生产必须使用 Supabase Auth access token。 + 查询启用规则: ```text diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 1fce7fdb..2bf2eae2 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -21,6 +21,7 @@ const PARTNER_TENANT_ADMIN_USER_ID = '00000000-0000-0000-0000-000000000907'; const AUTH_USER_ID = '00000000-0000-0000-0000-00000000a101'; const AUTH_TENANT_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a102'; const AUTH_PLATFORM_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a999'; +const AUTH_RESTRICTED_PLATFORM_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a998'; const AUTH_JWT_SECRET = 'development-jwt-secret-change-me'; const START_SERVER = process.argv.includes('--start-server'); const ENABLE_REAL_STORAGE_SIGN_TESTS = process.env.ENABLE_REAL_STORAGE_SIGN_TESTS === 'true'; @@ -1063,6 +1064,121 @@ async function testSupabaseJwtIdentity() { assert.equal(studentPlatformDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student Supabase JWT must not access platform APIs'); } +async function testPlatformAdminPermissions() { + const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL }); + const restrictedUserId = crypto.randomUUID(); + try { + await pool.query( + ` + insert into auth.users ( + id, aud, role, phone, phone_confirmed_at, raw_app_meta_data, raw_user_meta_data, + created_at, updated_at + ) + values ( + $1, 'authenticated', 'authenticated', '13999999998', now(), + '{"provider":"phone","providers":["phone"],"app_role":"platform_admin"}'::jsonb, + '{}'::jsonb, now(), now() + ) + on conflict (id) + do update set phone = excluded.phone, + raw_app_meta_data = excluded.raw_app_meta_data, + updated_at = now() + `, + [AUTH_RESTRICTED_PLATFORM_ADMIN_USER_ID], + ); + await pool.query( + ` + insert into public.platform_users ( + id, auth_user_id, username, phone, name, primary_role, platform_permissions, raw_profile + ) + values ( + $1, $2, 'restricted_platform_reader', '13999999998', 'Restricted Platform Reader', + 'platform_admin', + '{"platform:overview:read":true,"platform:tenant:read":true,"platform:plan:read":true}'::jsonb, + '{"source":"api-integration-test"}'::jsonb + ) + on conflict (auth_user_id) + do update set username = excluded.username, + phone = excluded.phone, + name = excluded.name, + primary_role = excluded.primary_role, + platform_permissions = excluded.platform_permissions, + updated_at = now() + `, + [restrictedUserId, AUTH_RESTRICTED_PLATFORM_ADMIN_USER_ID], + ); + } finally { + await pool.end(); + } + + const restrictedJwt = await createSupabaseJwt(AUTH_RESTRICTED_PLATFORM_ADMIN_USER_ID, { + phone: '13999999998', + appRole: 'platform_admin', + tenantId: false, + }); + const restrictedHeaders = { authorization: `Bearer ${restrictedJwt}` }; + + const permissionSummary = await request('/api/platform-admin/permissions', { + tenantId: false, + userId: false, + headers: restrictedHeaders, + }); + assert.equal(permissionSummary.item?.effective?.['platform:tenant:read'], true, 'restricted platform admin should expose granted tenant read permission'); + assert.equal(permissionSummary.item?.effective?.['platform:tenant:write'], false, 'restricted platform admin should not expose tenant write permission'); + assert.equal(permissionSummary.item?.effective?.['platform:audit:export'], false, 'restricted platform admin should not expose audit export permission'); + assert.ok(!JSON.stringify(permissionSummary).includes('local-platform-admin-key'), 'platform permission summary must not leak platform admin key'); + + const overview = await request('/api/platform-admin/overview', { + tenantId: false, + userId: false, + headers: restrictedHeaders, + }); + assert.ok(overview.item?.tenants?.total >= 1, 'restricted platform admin should access explicitly granted overview'); + + const tenants = await request('/api/platform-admin/tenants', { + tenantId: false, + userId: false, + headers: restrictedHeaders, + }); + assert.ok(Array.isArray(tenants.items), 'restricted platform admin should list tenants with tenant read permission'); + + const createDenied = await request('/api/platform-admin/tenants', { + tenantId: false, + userId: false, + headers: restrictedHeaders, + method: 'POST', + body: { + slug: `restricted-denied-${Date.now().toString(36)}`, + name: 'Restricted Denied Tenant', + }, + expectStatus: 403, + }); + assert.equal(createDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'tenant write must require platform tenant write permission'); + + const auditExportDenied = await request('/api/platform-admin/audit-logs/export', { + tenantId: false, + userId: false, + headers: restrictedHeaders, + query: { format: 'csv' }, + expectStatus: 403, + }); + assert.equal(auditExportDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'audit export must require explicit platform audit export permission'); + + const paymentDenied = await request('/api/platform-admin/invoices/payments/manual-confirm', { + tenantId: false, + userId: false, + headers: restrictedHeaders, + method: 'POST', + body: { + tenantId: PARTNER_TENANT_ID, + invoiceId: ids.platformOverdueInvoice, + amountCents: 1, + }, + expectStatus: 403, + }); + assert.equal(paymentDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'manual service-fee payment must require billing payment permission'); +} + async function testSupabaseJwksIdentity() { const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); const publicJwk = await exportJWK(publicKey); @@ -8897,6 +9013,7 @@ async function main() { await check('trusted session identity', testTrustedSessionIdentity); await check('phone binding', testPhoneBinding); await check('Supabase JWT identity', testSupabaseJwtIdentity); + await check('platform admin permissions', testPlatformAdminPermissions); await check('Supabase JWKS JWT identity', testSupabaseJwksIdentity); await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled); await check('platform tenant operations and audit', testPlatformTenantOperationsAndAudit); diff --git a/scripts/import-pocketbase/src/import-json.ts b/scripts/import-pocketbase/src/import-json.ts index acc337ca..b8190f3e 100644 --- a/scripts/import-pocketbase/src/import-json.ts +++ b/scripts/import-pocketbase/src/import-json.ts @@ -832,11 +832,11 @@ async function normalizeUsers(records: JsonRecord[]) { ` insert into public.platform_users ( legacy_id, username, email, phone, name, avatar_url, primary_role, score, - last_seen_at, password_migration_required, raw_profile, created_at, updated_at + last_seen_at, password_migration_required, platform_permissions, raw_profile, created_at, updated_at ) - values ($1,$2,$3,$4,$5,$6,$7,$8,nullif($9::text,'')::timestamptz,true,$10, - coalesce(nullif($11::text,'')::timestamptz, now()), - coalesce(nullif($12::text,'')::timestamptz, now()) + values ($1,$2,$3,$4,$5,$6,$7,$8,nullif($9::text,'')::timestamptz,true,$10::jsonb,$11, + coalesce(nullif($12::text,'')::timestamptz, now()), + coalesce(nullif($13::text,'')::timestamptz, now()) ) on conflict (legacy_id) do update set username = excluded.username, @@ -846,6 +846,7 @@ async function normalizeUsers(records: JsonRecord[]) { avatar_url = excluded.avatar_url, primary_role = excluded.primary_role, score = excluded.score, + platform_permissions = excluded.platform_permissions, raw_profile = excluded.raw_profile, updated_at = excluded.updated_at `, @@ -856,9 +857,10 @@ async function normalizeUsers(records: JsonRecord[]) { text(r.phone), text(r.name) || text(r.username), text(r.avatar), - text(r.role) || 'student', + normalizeTenantRole(r.role), intValue(r.score), dateText(r.lastSeenAt), + normalizeTenantRole(r.role) === 'platform_admin' ? JSON.stringify({ '*': true }) : JSON.stringify({}), JSON.stringify(safeProfile), dateText(r.created), dateText(r.updated), diff --git a/scripts/production-readiness-check.js b/scripts/production-readiness-check.js index f3c9856e..4557a460 100644 --- a/scripts/production-readiness-check.js +++ b/scripts/production-readiness-check.js @@ -394,6 +394,27 @@ async function validateDatabase() { pass('db.provider_public_config', 'Active provider public configs do not contain secret-like keys'); } + const platformAdminsWithoutPermissions = await pool.query(` + select id, username, phone + from public.platform_users + where primary_role = 'platform_admin' + and (platform_permissions is null or platform_permissions = '{}'::jsonb) + order by created_at asc + limit 20 + `); + if (platformAdminsWithoutPermissions.rowCount > 0) { + block('db.platform_admin_permissions', 'Platform admin users must have explicit platform_permissions', { + count: platformAdminsWithoutPermissions.rowCount, + samples: platformAdminsWithoutPermissions.rows.map(row => ({ + id: row.id, + username: row.username, + phone: row.phone ? `${String(row.phone).slice(0, 3)}****${String(row.phone).slice(-4)}` : null, + })), + }); + } else { + pass('db.platform_admin_permissions', 'Platform admin users have explicit permission maps'); + } + const missingAuthSecretRows = await pool.query(` select p.tenant_id, p.provider from public.tenant_auth_providers p diff --git a/scripts/smoke-seed.js b/scripts/smoke-seed.js index 3286a2b2..ffbbf4ac 100644 --- a/scripts/smoke-seed.js +++ b/scripts/smoke-seed.js @@ -847,14 +847,15 @@ async function main() { await client.query( ` - insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, raw_profile) - values ($1, $2, 'smoke_platform_admin', '13999999999', 'Smoke Platform Admin', 'platform_admin', '{"source":"smoke-seed"}'::jsonb) + insert into public.platform_users (id, auth_user_id, username, phone, name, primary_role, platform_permissions, raw_profile) + values ($1, $2, 'smoke_platform_admin', '13999999999', 'Smoke Platform Admin', 'platform_admin', '{"*":true}'::jsonb, '{"source":"smoke-seed"}'::jsonb) on conflict (id) do update set username = excluded.username, auth_user_id = excluded.auth_user_id, phone = excluded.phone, name = excluded.name, primary_role = excluded.primary_role, + platform_permissions = excluded.platform_permissions, updated_at = now() `, [ids.platformAdminUser, ids.authPlatformAdminUser], diff --git a/supabase/migrations/202606300005_platform_admin_permissions.sql b/supabase/migrations/202606300005_platform_admin_permissions.sql new file mode 100644 index 00000000..5393facb --- /dev/null +++ b/supabase/migrations/202606300005_platform_admin_permissions.sql @@ -0,0 +1,10 @@ +alter table public.platform_users + add column if not exists platform_permissions jsonb not null default '{}'::jsonb; + +update public.platform_users +set platform_permissions = '{"*":true}'::jsonb +where primary_role = 'platform_admin' + and (platform_permissions = '{}'::jsonb or platform_permissions is null); + +create index if not exists idx_platform_users_platform_permissions_gin + on public.platform_users using gin (platform_permissions);