diff --git a/README.md b/README.md index 1e3d5e22..f3887285 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 配置、跟进分配策略和队列。 @@ -35,7 +35,7 @@ - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 - 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。 - 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决和积分活动任务自动发放已可联调;积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成,Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。后续还要补连续签到奖励规则、练习次数/单词掌握/模考成绩系统触发勋章、外部微信订阅消息/短信推送、积分风控报表、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。 -- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:`/`content_asset:` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、积分任务/兑换/积分明细和消息中心第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、租户详情、状态变更、账务资料维护、平台审计查询/CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费 worker 生成结果查看、收款、逾期预览/催缴记录、催缴外部通知渠道/事件摘要、用量和题库授权第一版写操作。 +- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:`/`content_asset:` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版,个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、积分任务/兑换/积分明细和消息中心第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权、平台员工管理,以及创建租户、租户详情、状态变更、账务资料维护、平台员工创建/编辑/禁用恢复、权限点勾选、平台审计查询/CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费 worker 生成结果查看、收款、逾期预览/催缴记录、催缴外部通知渠道/事件摘要、用量和题库授权第一版写操作。 - 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 更完整的进度看这些文档: @@ -168,6 +168,7 @@ apps/taro/src/pages/platform-admin/workbench apps/taro/src/pages/platform-admin/tenants apps/taro/src/pages/platform-admin/billing apps/taro/src/pages/platform-admin/question-banks +apps/taro/src/pages/platform-admin/staff ``` 单次运行 CRM worker: @@ -474,8 +475,9 @@ API 身份上下文: - 平台账号以后端 `platform_users.primary_role='platform_admin'` 为准,不只信 JWT claim。 - 平台账号通过 `platform_users.platform_permissions` 控制细粒度能力,`{"*":true}` 表示超级管理员。 +- 平台员工通过 `GET/PUT/PATCH /api/platform-admin/staff` 管理,必须绑定 Supabase Auth 用户 ID;禁用员工会使 `status='disabled'`,后续 Supabase JWT 映射和迁移期 session 都会被拒绝。 - 平台后台启动后可调用 `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` 等权限点强制校验。 +- 后端接口继续按 `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` 等权限点强制校验。 - `x-platform-admin-key` 只允许本地兼容,生产必须关闭。 ## 重要安全约定 @@ -522,7 +524,7 @@ git diff --check 优先继续补: 1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。 -2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台在线收款、审计报表增强、平台员工创建/授权 UI、审计告警通知升级策略、催缴通知操作台细节和小程序兼容验证。 +2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 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 7444d420..1e45e728 100644 --- a/apps/api/src/core/auth-context.ts +++ b/apps/api/src/core/auth-context.ts @@ -56,6 +56,7 @@ export async function findUserBySessionToken(token: string) { from app_private.auth_sessions s join public.platform_users u on u.id = s.user_id where s.token_hash = $1 + and u.status = 'active' and s.revoked_at is null and s.expires_at > now() limit 1 @@ -142,6 +143,7 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex 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 + and u.status = 'active' and u.primary_role = 'platform_admin' and ($2::uuid is null or exists ( select 1 @@ -175,6 +177,7 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex from public.platform_users u join public.tenant_memberships tm on tm.user_id = u.id where u.auth_user_id = $1::uuid + and u.status = 'active' and tm.status = 'active' and tm.tenant_id = $2::uuid order by case diff --git a/apps/api/src/features/platform-admin/index.ts b/apps/api/src/features/platform-admin/index.ts index 6065fe97..50227c0f 100644 --- a/apps/api/src/features/platform-admin/index.ts +++ b/apps/api/src/features/platform-admin/index.ts @@ -19,6 +19,7 @@ import { platformPermissionsRoute, platformPlansRoute, platformQuestionBanksRoute, + platformStaffRoute, processOverdueInvoicesRoute, questionBankGrantsRoute, recordUsageRoute, @@ -27,8 +28,10 @@ import { tenantInvoicesRoute, tenantsRoute, tenantUsageRoute, + updatePlatformStaffStatusRoute, updatePlatformAuditAlertStatusRoute, updateTenantStatusRoute, + upsertPlatformStaffRoute, upsertPlatformAuditNotificationChannelRoute, upsertPlatformDunningNotificationChannelRoute, upsertQuestionBankGrantRoute, @@ -37,6 +40,9 @@ import { export const platformAdminRoutes: RouteDefinition[] = [ ['GET', '/api/platform-admin/permissions', platformPermissionsRoute], + ['GET', '/api/platform-admin/staff', platformStaffRoute], + ['PUT', '/api/platform-admin/staff', upsertPlatformStaffRoute], + ['PATCH', '/api/platform-admin/staff/status', updatePlatformStaffStatusRoute], ['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 55911a2c..c7c86651 100644 --- a/apps/api/src/features/platform-admin/routes.ts +++ b/apps/api/src/features/platform-admin/routes.ts @@ -45,11 +45,15 @@ function objectValue(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } +function booleanValue(value: unknown, fallback = false) { + return typeof value === 'boolean' ? value : fallback; +} + function truncate(value: unknown, max = 1900) { return String(value ?? '').slice(0, max); } -const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const TENANT_INVOICE_STATUSES = new Set(['draft', 'issued', 'paid', 'void', 'overdue']); const PLATFORM_AUDIT_ALERT_STATUSES = new Set(['open', 'acknowledged', 'resolved', 'ignored']); const PLATFORM_AUDIT_NOTIFICATION_EVENT_STATUSES = new Set(['pending', 'processing', 'sent', 'retrying', 'failed', 'discarded']); @@ -57,9 +61,13 @@ const PLATFORM_AUDIT_NOTIFICATION_PROVIDERS = new Set(['generic', 'dingtalk', 'f 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_STAFF_STATUSES = new Set(['active', 'disabled']); const PLATFORM_PERMISSION_CATALOG = [ { key: 'platform:overview:read', group: 'overview', label: '平台概览' }, + { key: 'platform:staff:read', group: 'staff', label: '查看平台员工' }, + { key: 'platform:staff:write', group: 'staff', label: '创建/编辑平台员工' }, + { key: 'platform:staff:status', group: 'staff', label: '启停平台员工' }, { key: 'platform:tenant:read', group: 'tenant', label: '查看租户' }, { key: 'platform:tenant:write', group: 'tenant', label: '创建/编辑租户' }, { key: 'platform:tenant:status', group: 'tenant', label: '变更租户状态' }, @@ -113,6 +121,18 @@ function redactAuditExportValue(value: unknown, parentKey = '', depth = 0): unkn const redactAuditAlertValue = redactAuditExportValue; +type PlatformStaffPublicRow = Record & { + rawProfile?: unknown; +}; + +function platformStaffPublicRow(item: T): T { + if (!Object.prototype.hasOwnProperty.call(item, 'rawProfile')) return item; + return { + ...item, + rawProfile: redactAuditExportValue(item.rawProfile), + }; +} + function contentBase64AndHash(content: string) { const buffer = Buffer.from(content, 'utf8'); return { @@ -498,6 +518,67 @@ function grantStatusFrom(value: string) { return status; } +function platformStaffStatusFrom(value: string, fallback = 'active') { + const status = value || fallback; + if (!PLATFORM_STAFF_STATUSES.has(status)) { + throw new HttpError(400, 'status is invalid', 'INVALID_STATUS'); + } + return status; +} + +function normalizeOptionalEmail(value: string) { + if (!value) return null; + const email = value.toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) { + throw new HttpError(400, 'email is invalid', 'INVALID_EMAIL'); + } + return email; +} + +function normalizeOptionalPhone(value: string) { + if (!value) return null; + const phone = value.replace(/\s+/g, ''); + if (!/^\+?[0-9-]{6,32}$/.test(phone)) { + throw new HttpError(400, 'phone is invalid', 'INVALID_PHONE'); + } + return phone; +} + +function normalizePlatformUsername(value: string, fallback: string) { + const username = (value || fallback).trim(); + if (!/^[a-zA-Z0-9_.@-]{3,80}$/.test(username)) { + throw new HttpError(400, 'username is invalid', 'INVALID_USERNAME'); + } + return username; +} + +function allowedPlatformPermissionKeys() { + return new Set(PLATFORM_PERMISSION_CATALOG.map(item => item.key)); +} + +function normalizePlatformPermissions(value: unknown) { + const input = value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : {}; + const allowed = allowedPlatformPermissionKeys(); + const output: Record = {}; + for (const [key, enabled] of Object.entries(input)) { + if (enabled !== true) continue; + const permission = key.trim(); + const domainWildcard = /^platform:[a-z_]+:\*$/.test(permission); + const validWildcard = domainWildcard && [...allowed].some(item => item.startsWith(permission.slice(0, -1))); + if (permission !== '*' && !allowed.has(permission) && !validWildcard) { + throw new HttpError(400, `Platform permission ${permission} is invalid`, 'INVALID_PLATFORM_PERMISSION'); + } + output[permission] = true; + } + return output; +} + +function platformPermissionKeys(permissions: Record) { + return Object.keys(permissions).filter(key => permissions[key] === true).sort(); +} + function platformPermissionAllowed(permissions: Record, permission: string) { if (permissions['*'] === true) return true; if (permissions[permission] === true) return true; @@ -528,6 +609,239 @@ export async function platformPermissionsRoute(ctx: RequestContext) { }; } +export async function platformStaffRoute(ctx: RequestContext) { + await requirePlatformAdmin(ctx, 'platform:staff:read'); + + const status = listQuery(ctx, 'status'); + if (status && !PLATFORM_STAFF_STATUSES.has(status)) throw new HttpError(400, 'status is invalid', 'INVALID_STATUS'); + const q = listQuery(ctx, 'q'); + const limit = intParam(ctx, 'limit', 50, 200); + + const items = await query( + ` + select id, auth_user_id as "authUserId", username, email::text, phone, name, avatar_url as "avatarUrl", + primary_role as "primaryRole", status, platform_permissions as "platformPermissions", + raw_profile as "rawProfile", last_seen_at as "lastSeenAt", + created_at as "createdAt", updated_at as "updatedAt" + from public.platform_users + where primary_role = 'platform_admin' + and ($1::text = '' or status = $1) + and ( + $2::text = '' + or coalesce(username, '') ilike '%' || $2 || '%' + or coalesce(name, '') ilike '%' || $2 || '%' + or coalesce(phone, '') ilike '%' || $2 || '%' + or coalesce(email::text, '') ilike '%' || $2 || '%' + ) + order by status asc, created_at desc + limit $3 + `, + [status, q, limit], + ); + + return { items: items.map(platformStaffPublicRow) }; +} + +export async function upsertPlatformStaffRoute(ctx: RequestContext) { + await requirePlatformAdmin(ctx, 'platform:staff:write'); + + const body = await readJsonBody(ctx); + const staffId = optionalString(body, 'id'); + if (staffId && !UUID_RE.test(staffId)) throw new HttpError(400, 'id is invalid', 'INVALID_UUID'); + const authUserId = requiredString(body, 'authUserId'); + if (!UUID_RE.test(authUserId)) throw new HttpError(400, 'authUserId is invalid', 'INVALID_UUID'); + + const email = normalizeOptionalEmail(optionalString(body, 'email')); + const phone = normalizeOptionalPhone(optionalString(body, 'phone')); + const username = normalizePlatformUsername(optionalString(body, 'username'), email || phone || `platform_${Date.now().toString(36)}`); + const name = requiredString(body, 'name'); + const avatarUrl = optionalString(body, 'avatarUrl') || null; + const status = platformStaffStatusFrom(optionalString(body, 'status'), 'active'); + const permissions = normalizePlatformPermissions(body.platformPermissions); + if (status === 'active' && platformPermissionKeys(permissions).length === 0) { + throw new HttpError(400, 'Active platform staff must have at least one permission', 'PLATFORM_PERMISSION_EMPTY'); + } + const metadata = objectValue(body.metadata); + const session = currentSessionFromContext(ctx); + + const item = await transaction(async client => { + let targetStaffId = staffId || null; + + const authUser = await client.query( + ` + select id + from auth.users + where id = $1::uuid + limit 1 + `, + [authUserId], + ); + if (authUser.rowCount === 0) { + throw new HttpError(404, 'Supabase Auth user not found', 'AUTH_USER_NOT_FOUND'); + } + + const existingByAuth = await client.query( + ` + select id, primary_role as "primaryRole" + from public.platform_users + where auth_user_id = $1::uuid + for update + `, + [authUserId], + ); + const existing = existingByAuth.rows[0]; + if (existing) { + if (staffId && existing.id !== staffId) { + throw new HttpError(409, 'authUserId is already bound to another platform user', 'AUTH_USER_ALREADY_BOUND'); + } + if (existing.primaryRole !== 'platform_admin') { + throw new HttpError(409, 'authUserId is already bound to a non-platform account', 'AUTH_USER_ALREADY_BOUND'); + } + targetStaffId = existing.id; + } + + if (targetStaffId) { + const existingById = await client.query( + ` + select id, auth_user_id as "authUserId", primary_role as "primaryRole" + from public.platform_users + where id = $1::uuid + for update + `, + [targetStaffId], + ); + const existing = existingById.rows[0]; + if (existing && existing.primaryRole !== 'platform_admin') { + throw new HttpError(409, 'Platform staff id is already used by a non-platform account', 'PLATFORM_USER_ROLE_CONFLICT'); + } + if (session?.id === targetStaffId && status !== 'active') { + throw new HttpError(400, 'Current platform admin cannot disable itself', 'CANNOT_DISABLE_SELF'); + } + if (session?.id === targetStaffId && existing.authUserId && existing.authUserId !== authUserId) { + throw new HttpError(400, 'Current platform admin cannot change its own Auth binding', 'CANNOT_REBIND_SELF'); + } + if (session?.id === targetStaffId && permissions['*'] !== true) { + throw new HttpError(400, 'Current platform admin cannot remove its own super permission', 'CANNOT_DOWNGRADE_SELF'); + } + } + + const result = await client.query( + ` + insert into public.platform_users ( + id, auth_user_id, username, email, phone, name, avatar_url, + primary_role, status, platform_permissions, raw_profile + ) + values ( + coalesce($1::uuid, gen_random_uuid()), $2::uuid, $3, $4::citext, $5, $6, $7, + 'platform_admin', $8, $9::jsonb, + jsonb_strip_nulls(coalesce($10::jsonb, '{}'::jsonb) || jsonb_build_object( + 'source', 'platform-admin:staff', + 'managedByPlatform', true + )) + ) + on conflict (id) + do update set auth_user_id = coalesce(excluded.auth_user_id, public.platform_users.auth_user_id), + username = excluded.username, + email = excluded.email, + phone = excluded.phone, + name = excluded.name, + avatar_url = excluded.avatar_url, + primary_role = 'platform_admin', + status = excluded.status, + platform_permissions = excluded.platform_permissions, + raw_profile = jsonb_strip_nulls(public.platform_users.raw_profile || excluded.raw_profile), + updated_at = now() + returning id, auth_user_id as "authUserId", username, email::text, phone, name, + avatar_url as "avatarUrl", primary_role as "primaryRole", status, + platform_permissions as "platformPermissions", raw_profile as "rawProfile", + created_at as "createdAt", updated_at as "updatedAt" + `, + [ + targetStaffId, + authUserId || null, + username, + email, + phone, + name, + avatarUrl, + status, + JSON.stringify(permissions), + JSON.stringify(metadata), + ], + ); + const saved = result.rows[0]; + await recordPlatformAudit(client, ctx, 'platform.staff.upserted', 'platform_user', saved.id, { + username, + name, + status, + authUserBound: Boolean(authUserId), + emailSet: Boolean(email), + phoneSet: Boolean(phone), + permissionKeys: platformPermissionKeys(permissions), + }); + return platformStaffPublicRow(saved); + }); + + return { item }; +} + +export async function updatePlatformStaffStatusRoute(ctx: RequestContext) { + await requirePlatformAdmin(ctx, 'platform:staff:status'); + + const body = await readJsonBody(ctx); + const staffId = requiredString(body, 'staffId'); + if (!UUID_RE.test(staffId)) throw new HttpError(400, 'staffId is invalid', 'INVALID_UUID'); + const status = platformStaffStatusFrom(optionalString(body, 'status')); + const reason = optionalString(body, 'reason') || null; + const revokeSessions = booleanValue(body.revokeSessions, true); + const session = currentSessionFromContext(ctx); + if (session?.id === staffId && status !== 'active') { + throw new HttpError(400, 'Current platform admin cannot disable itself', 'CANNOT_DISABLE_SELF'); + } + + const item = await transaction(async client => { + const result = await client.query( + ` + update public.platform_users + set status = $2, + raw_profile = jsonb_strip_nulls(raw_profile || jsonb_build_object( + 'platformStatusReason', $3::text, + 'platformStatusUpdatedAt', now() + )), + updated_at = now() + where id = $1 + and primary_role = 'platform_admin' + returning id, auth_user_id as "authUserId", username, email::text, phone, name, + primary_role as "primaryRole", status, platform_permissions as "platformPermissions", + updated_at as "updatedAt" + `, + [staffId, status, reason], + ); + const saved = result.rows[0]; + if (!saved) throw new HttpError(404, 'Platform staff not found', 'PLATFORM_STAFF_NOT_FOUND'); + + if (status === 'disabled' && revokeSessions) { + await client.query( + ` + update app_private.auth_sessions + set revoked_at = now() + where user_id = $1 and revoked_at is null + `, + [staffId], + ); + } + + await recordPlatformAudit(client, ctx, 'platform.staff.status_updated', 'platform_user', staffId, { + status, + reasonSet: Boolean(reason), + revokeSessions, + }); + return saved; + }); + + return { item }; +} + export async function platformOverviewRoute(ctx: RequestContext) { await requirePlatformAdmin(ctx, 'platform:overview:read'); diff --git a/apps/taro/src/app.config.ts b/apps/taro/src/app.config.ts index 74eb864a..2bb5448f 100644 --- a/apps/taro/src/app.config.ts +++ b/apps/taro/src/app.config.ts @@ -28,6 +28,7 @@ export default defineAppConfig({ 'pages/platform-admin/tenants/index', 'pages/platform-admin/billing/index', 'pages/platform-admin/question-banks/index', + 'pages/platform-admin/staff/index', ], window: { backgroundTextStyle: 'light', diff --git a/apps/taro/src/pages/platform-admin/platform.css b/apps/taro/src/pages/platform-admin/platform.css index 8c887e52..96d5e81c 100644 --- a/apps/taro/src/pages/platform-admin/platform.css +++ b/apps/taro/src/pages/platform-admin/platform.css @@ -219,6 +219,74 @@ color: #be123c; } +.platform-mini-button.active { + border-color: #1d4ed8; + background: #eff6ff; + color: #1d4ed8; +} + +.platform-permission-panel { + display: flex; + flex-direction: column; + gap: 16px; + margin: 8px 0 16px; + padding: 18px; + border: 1px solid #dbe4f0; + border-radius: 8px; + background: #fff; +} + +.platform-permission-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.platform-permission-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.platform-chip-list { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.platform-chip-list.compact { + margin-top: 14px; +} + +.platform-chip { + min-width: 0; + max-width: 100%; + min-height: 48px; + padding: 0 14px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: #f8fafc; + color: #334155; + font-size: 20px; + font-weight: 680; + line-height: 48px; +} + +.platform-chip.active { + border-color: #1d4ed8; + background: #eff6ff; + color: #1d4ed8; +} + +.platform-chip.readonly { + display: inline-flex; + align-items: center; + max-width: 100%; + color: #475569; + line-height: 1.25; +} + .platform-error { display: block; margin-top: 12px; diff --git a/apps/taro/src/pages/platform-admin/staff/index.config.ts b/apps/taro/src/pages/platform-admin/staff/index.config.ts new file mode 100644 index 00000000..57936790 --- /dev/null +++ b/apps/taro/src/pages/platform-admin/staff/index.config.ts @@ -0,0 +1,3 @@ +export default definePageConfig({ + navigationBarTitleText: '平台员工', +}); diff --git a/apps/taro/src/pages/platform-admin/staff/index.tsx b/apps/taro/src/pages/platform-admin/staff/index.tsx new file mode 100644 index 00000000..4fbff525 --- /dev/null +++ b/apps/taro/src/pages/platform-admin/staff/index.tsx @@ -0,0 +1,359 @@ +import { useEffect, useMemo, useState } from 'react'; +import Taro from '@tarojs/taro'; +import { Button, Input, Text, View } from '@tarojs/components'; +import { + loadPlatformPermissions, + loadPlatformStaff, + updatePlatformStaffStatus, + upsertPlatformStaff, + type PlatformPermissionCatalogItem, + type PlatformPermissionSummary, + type PlatformStaffItem, +} from '@/services/platformAdmin'; +import '../platform.css'; + +type StaffForm = { + id: string; + authUserId: string; + username: string; + name: string; + email: string; + phone: string; + avatarUrl: string; + status: string; + platformPermissions: Record; +}; + +const emptyForm: StaffForm = { + id: '', + authUserId: '', + username: '', + name: '', + email: '', + phone: '', + avatarUrl: '', + status: 'active', + platformPermissions: {}, +}; + +function dateText(value?: string | null) { + return value ? String(value).slice(0, 19).replace('T', ' ') : '-'; +} + +function permissionKeys(value?: Record | null) { + return Object.entries(value || {}) + .filter(([, enabled]) => enabled === true) + .map(([key]) => key) + .sort(); +} + +function permissionLabel(item: PlatformPermissionCatalogItem) { + return item.label || item.key; +} + +function groupLabel(group?: string | null) { + const labels: Record = { + overview: '概览', + staff: '员工', + tenant: '租户', + billing: '账务', + usage: '用量', + audit: '审计', + question_bank: '公共题库', + }; + return labels[group || ''] || group || '其他'; +} + +function fromStaff(item: PlatformStaffItem): StaffForm { + const platformPermissions: Record = {}; + for (const key of permissionKeys(item.platformPermissions)) { + platformPermissions[key] = true; + } + return { + id: item.id || '', + authUserId: item.authUserId || '', + username: item.username || '', + name: item.name || '', + email: item.email || '', + phone: item.phone || '', + avatarUrl: item.avatarUrl || '', + status: item.status || 'active', + platformPermissions, + }; +} + +export default function PlatformStaffPage() { + const [keyword, setKeyword] = useState(''); + const [status, setStatus] = useState(''); + const [staff, setStaff] = useState([]); + const [permissionSummary, setPermissionSummary] = useState(null); + const [form, setForm] = useState(emptyForm); + const [busy, setBusy] = useState(''); + const [error, setError] = useState(''); + + const catalog = permissionSummary?.catalog || []; + const effective = permissionSummary?.effective || {}; + const canWrite = effective['platform:staff:write'] === true || effective['*'] === true; + const canChangeStatus = effective['platform:staff:status'] === true || effective['*'] === true; + + const groupedCatalog = useMemo(() => { + const groups: Array<{ group: string; items: PlatformPermissionCatalogItem[] }> = []; + const byGroup = new Map(); + for (const item of catalog) { + const group = item.group || 'other'; + byGroup.set(group, [...(byGroup.get(group) || []), item]); + } + for (const [group, items] of byGroup.entries()) { + groups.push({ group, items }); + } + return groups; + }, [catalog]); + + function reload(nextStatus = status, nextKeyword = keyword) { + setError(''); + loadPlatformStaff({ q: nextKeyword || undefined, status: nextStatus || undefined, limit: 120 }) + .then(payload => setStaff(payload.items || [])) + .catch(nextError => setError(nextError instanceof Error ? nextError.message : '平台员工加载失败')); + } + + useEffect(() => { + Promise.all([ + loadPlatformPermissions().catch(() => ({ item: null })), + loadPlatformStaff({ limit: 120 }).catch(() => ({ items: [] })), + ]).then(([permissionPayload, staffPayload]) => { + setPermissionSummary(permissionPayload.item || null); + setStaff(staffPayload.items || []); + }).catch(nextError => setError(nextError instanceof Error ? nextError.message : '平台员工页面加载失败')); + }, []); + + function chooseStatus(nextStatus: string) { + setStatus(nextStatus); + reload(nextStatus, keyword); + } + + function updateForm(key: keyof StaffForm, value: string) { + setForm(current => ({ ...current, [key]: value })); + } + + function togglePermission(key: string) { + setForm(current => { + const nextPermissions = { ...current.platformPermissions }; + if (nextPermissions[key]) { + delete nextPermissions[key]; + } else { + nextPermissions[key] = true; + } + return { ...current, platformPermissions: nextPermissions }; + }); + } + + function toggleSuperPermission() { + togglePermission('*'); + } + + function editStaff(item: PlatformStaffItem) { + setForm(fromStaff(item)); + } + + function resetForm() { + setForm(emptyForm); + } + + async function confirm(title: string, content: string) { + const result = await Taro.showModal({ title, content, confirmText: '确认', cancelText: '取消' }); + return result.confirm; + } + + async function submitStaff() { + setError(''); + if (!canWrite) { + setError('当前账号没有 platform:staff:write 权限。'); + return; + } + if (!form.name.trim()) { + setError('平台员工必须填写姓名。'); + return; + } + if (!form.authUserId.trim()) { + setError('平台员工必须绑定 Supabase Auth 用户 ID,生产环境不允许悬空账号。'); + return; + } + const keys = permissionKeys(form.platformPermissions); + if (!keys.length) { + setError('平台员工至少需要配置一个平台权限。'); + return; + } + const ok = await confirm( + form.id ? '更新平台员工' : '创建平台员工', + form.platformPermissions['*'] + ? '该员工将拥有平台超级权限,请确认这是必要授权。' + : `确认保存员工 ${form.name.trim()} 的 ${keys.length} 个权限点?`, + ); + if (!ok) return; + setBusy('save'); + try { + const payload = await upsertPlatformStaff({ + id: form.id || undefined, + authUserId: form.authUserId.trim(), + username: form.username.trim() || undefined, + name: form.name.trim(), + email: form.email.trim() || undefined, + phone: form.phone.trim() || undefined, + avatarUrl: form.avatarUrl.trim() || undefined, + status: form.status || 'active', + platformPermissions: form.platformPermissions, + }); + Taro.showToast({ title: '已保存', icon: 'success' }); + if (payload.item) setForm(fromStaff(payload.item)); + reload(status, keyword); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '平台员工保存失败'); + } finally { + setBusy(''); + } + } + + async function submitStatus(item: PlatformStaffItem, nextStatus: 'active' | 'disabled') { + setError(''); + if (!canChangeStatus) { + setError('当前账号没有 platform:staff:status 权限。'); + return; + } + const ok = await confirm( + nextStatus === 'disabled' ? '禁用平台员工' : '恢复平台员工', + nextStatus === 'disabled' + ? `确认禁用 ${item.name || item.username || item.id}?后端会默认撤销迁移期 session,Supabase JWT 也会因 status=disabled 被拒绝。` + : `确认恢复 ${item.name || item.username || item.id} 的平台后台访问?`, + ); + if (!ok) return; + setBusy(`status-${item.id}-${nextStatus}`); + try { + await updatePlatformStaffStatus({ + staffId: item.id, + status: nextStatus, + reason: nextStatus === 'disabled' ? 'platform admin disabled from staff page' : 'platform admin restored from staff page', + revokeSessions: nextStatus === 'disabled', + }); + Taro.showToast({ title: nextStatus === 'disabled' ? '已禁用' : '已恢复', icon: 'success' }); + reload(status, keyword); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : '员工状态更新失败'); + } finally { + setBusy(''); + } + } + + const activeCount = staff.filter(item => item.status === 'active').length; + const disabledCount = staff.filter(item => item.status === 'disabled').length; + const selectedPermissions = permissionKeys(form.platformPermissions); + + return ( + + + + Staff + 平台员工 + 绑定 Supabase Auth 账号,按平台权限点授予租户、账务、审计和公共题库后台能力。 + + + + setKeyword(String(event.detail.value || ''))} /> + + + + + + {[ + { label: '全部', value: '' }, + { label: 'active', value: 'active' }, + { label: 'disabled', value: 'disabled' }, + ].map(item => ( + + ))} + + + + 员工总数{String(staff.length)} + 可登录{String(activeCount)} + 已禁用{String(disabledCount)} + 当前权限点{String(selectedPermissions.length)} + + + + {form.id ? '编辑平台员工' : '创建平台员工'} + + 员工 ID updateForm('id', String(event.detail.value || ''))} /> + Supabase Auth 用户 ID updateForm('authUserId', String(event.detail.value || ''))} /> + 用户名 updateForm('username', String(event.detail.value || ''))} /> + 姓名 updateForm('name', String(event.detail.value || ''))} /> + 邮箱 updateForm('email', String(event.detail.value || ''))} /> + 手机号 updateForm('phone', String(event.detail.value || ''))} /> + 状态 updateForm('status', String(event.detail.value || ''))} /> + 头像 URL updateForm('avatarUrl', String(event.detail.value || ''))} /> + + + + + 权限点 + + + {groupedCatalog.map(group => ( + + {groupLabel(group.group)} + + {group.items.map(item => ( + + ))} + + + ))} + {!catalog.length ? 当前账号无法读取权限目录,或平台鉴权未通过。 : null} + + + + + + + + + + 员工列表 + + {staff.map(item => { + const keys = permissionKeys(item.platformPermissions); + return ( + + {item.name || item.username || item.id} + {item.username || '-'} · {item.status || '-'} · {item.email || '-'} · {item.phone || '-'} + Auth {item.authUserId || '未绑定'} · 最近登录 {dateText(item.lastSeenAt)} · 创建 {dateText(item.createdAt)} + + {keys.slice(0, 10).map(key => {key})} + {keys.length > 10 ? +{keys.length - 10} : null} + {!keys.length ? 无权限 : null} + + + + {item.status === 'disabled' ? ( + + ) : ( + + )} + + + ); + })} + + {!staff.length ? 暂无平台员工,或当前账号没有查看权限。 : null} + + + {error ? {error} : null} + + + ); +} diff --git a/apps/taro/src/pages/platform-admin/workbench/index.tsx b/apps/taro/src/pages/platform-admin/workbench/index.tsx index e4867b0c..90919ab9 100644 --- a/apps/taro/src/pages/platform-admin/workbench/index.tsx +++ b/apps/taro/src/pages/platform-admin/workbench/index.tsx @@ -91,6 +91,7 @@ export default function PlatformWorkbenchPage() { { name: '租户管理', path: '/pages/platform-admin/tenants/index', meta: '租户状态、套餐、欠费和到期' }, { name: '账务中心', path: '/pages/platform-admin/billing/index', meta: 'SaaS 套餐、发票、收款、用量' }, { name: '公共题库', path: '/pages/platform-admin/question-banks/index', meta: '地区题库、授权、披露范围' }, + { name: '平台员工', path: '/pages/platform-admin/staff/index', meta: '员工账号、平台权限、禁用恢复' }, ]; async function exportAuditLogs() { @@ -161,6 +162,7 @@ export default function PlatformWorkbenchPage() { + diff --git a/apps/taro/src/services/platformAdmin.ts b/apps/taro/src/services/platformAdmin.ts index 703c2b5b..3ec4149b 100644 --- a/apps/taro/src/services/platformAdmin.ts +++ b/apps/taro/src/services/platformAdmin.ts @@ -38,6 +38,23 @@ export interface PlatformPermissionSummary { catalog?: PlatformPermissionCatalogItem[]; } +export interface PlatformStaffItem { + id: string; + authUserId?: string | null; + username?: string | null; + email?: string | null; + phone?: string | null; + name?: string | null; + avatarUrl?: string | null; + primaryRole?: string | null; + status?: string | null; + platformPermissions?: Record | null; + rawProfile?: Record | null; + lastSeenAt?: string | null; + createdAt?: string | null; + updatedAt?: string | null; +} + export interface PlatformSaasPlan { id: string; code: string; @@ -478,6 +495,26 @@ export interface UpsertPlatformQuestionBankGrantInput { expiresAt?: string; } +export interface UpsertPlatformStaffInput { + id?: string; + authUserId?: string; + username?: string; + email?: string; + phone?: string; + name: string; + avatarUrl?: string; + status?: string; + platformPermissions?: Record; + metadata?: Record; +} + +export interface UpdatePlatformStaffStatusInput { + staffId: string; + status: 'active' | 'disabled'; + reason?: string; + revokeSessions?: boolean; +} + export async function loadPlatformOverview() { return apiRequest<{ item?: PlatformOverview }>('/api/platform-admin/overview', { tenantId: null }); } @@ -486,6 +523,29 @@ export async function loadPlatformPermissions() { return apiRequest<{ item?: PlatformPermissionSummary }>('/api/platform-admin/permissions', { tenantId: null }); } +export async function loadPlatformStaff(query: { q?: string; status?: string; limit?: number } = {}) { + return apiRequest<{ items?: PlatformStaffItem[] }>('/api/platform-admin/staff', { + query: { ...query, limit: query.limit || 80 }, + tenantId: null, + }); +} + +export async function upsertPlatformStaff(input: UpsertPlatformStaffInput) { + return apiRequest<{ item?: PlatformStaffItem }>('/api/platform-admin/staff', { + method: 'PUT', + body: input, + tenantId: null, + }); +} + +export async function updatePlatformStaffStatus(input: UpdatePlatformStaffStatusInput) { + return apiRequest<{ item?: PlatformStaffItem }>('/api/platform-admin/staff/status', { + method: 'PATCH', + body: input, + tenantId: null, + }); +} + export async function loadPlatformPlans(includeArchived = false) { return apiRequest<{ items?: PlatformSaasPlan[] }>('/api/platform-admin/plans', { query: { includeArchived }, diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 3c0680ec..2898477a 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;平台账号以后端 `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` 仅作本地/迁移期兼容且可通过配置禁用 | +| 平台管理员鉴权 | 可联调 | 已支持平台管理员 Supabase JWT;平台账号以后端 `platform_users.primary_role='platform_admin'`、`status='active'` 和 `platform_permissions` 为准;`GET /api/platform-admin/permissions` 返回权限目录和当前账号 `effective` 能力,平台路由按 `platform:staff:*`、`platform:tenant:*`、`platform:billing:*`、`platform:audit:*`、`platform:question_bank:*` 等权限点强制校验;`GET/PUT/PATCH /api/platform-admin/staff` 可管理平台员工,禁用员工会拒绝后续 JWT 映射并撤销迁移期 session;`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、重复开票保护、收款、逾期标记、内部催缴台账、催缴外部通知渠道和发送事件、用量;平台 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 会脱敏;创建租户、状态变更、账务资料维护、订阅批量开票、自动开票、逾期催缴、手工收款确认、审计导出、告警状态更新、通知渠道变更和催缴通知渠道变更会写入审计 | +| 平台租户/详情/账务资料/员工/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/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 | diff --git a/docs/refactor/backend-handoff-roadmap.md b/docs/refactor/backend-handoff-roadmap.md index 715caef7..da9c4427 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、公共题库同步通知第一版 | 平台员工账号创建/授权 UI 与管理 API、平台在线收款、平台审计告警升级策略和更完整运营消息 | +| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台账号细粒度权限目录、平台员工列表/创建/编辑/启停、平台路由权限强校验、平台审计日志查询、平台审计 CSV/JSON 导出、平台审计告警规则/列表/确认/解决、platform-audit-alerts worker、审计告警外部通知渠道/事件 API、platform-audit-notifications worker、套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费、人工收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件 API、platform-dunning-notifications worker、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 平台在线收款、平台审计告警升级策略和更完整运营消息 | | 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 | | 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 | | 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 | @@ -35,7 +35,7 @@ | 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告;Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 | | 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 | | AI 择校推荐 | 可联调 | `ai_recommendation_reports`、SVIP 门禁、学生输入 schema、地区/分数线上下文、`local_rules` 稳定 JSON、报告列表/详情和 Taro 学生端基础页 | 真实 AI provider、prompt 版本管理、租户后台配置、报告 PDF 渲染和人工复核流程 | -| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作、租户详情、账务资料编辑、平台审计查询和 CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅账单候选预览/dry-run/批量生成、自动计费生成结果查看、逾期预览、催缴记录和催缴外部通知摘要第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 长题干/公式图片混排体验、更细数据范围 UI、平台审计告警升级策略、平台催缴通知配置操作台细节、小程序兼容验证和端到端测试 | +| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作、租户详情、账务资料编辑、平台员工列表/创建/编辑/禁用恢复、平台审计查询和 CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅账单候选预览/dry-run/批量生成、自动计费生成结果查看、逾期预览、催缴记录和催缴外部通知摘要第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 长题干/公式图片混排体验、更细数据范围 UI、平台审计告警升级策略、平台催缴通知配置操作台细节、小程序兼容验证和端到端测试 | ## 前端接入建议 @@ -79,7 +79,7 @@ - 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和转码/CDN 级水印。 - 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings`,确认 `migrationReadiness` 的必需集合和关键字段覆盖率通过,再跑迁移和校验报告。 - 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。 -- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口、积分任务/兑换/积分明细和消息中心第一版;平台后台关键写操作、租户详情、账务资料编辑、最近平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、逾期预览、催缴记录和催缴外部通知摘要第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版;下一步补独立消息中心增强、公式图片混排、更细数据范围 UI、平台审计告警升级策略、平台催缴通知配置操作台细节和小程序兼容验证。 +- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口、积分任务/兑换/积分明细和消息中心第一版;平台后台关键写操作、租户详情、账务资料编辑、平台员工列表/创建/编辑/禁用恢复、最近平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、逾期预览、催缴记录和催缴外部通知摘要第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版;下一步补独立消息中心增强、公式图片混排、更细数据范围 UI、平台审计告警升级策略、平台催缴通知配置操作台细节和小程序兼容验证。 ### P1:商用收费和运营能力 diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index 725611bc..d6229e1e 100644 --- a/docs/refactor/blueprint-coverage.md +++ b/docs/refactor/blueprint-coverage.md @@ -15,7 +15,7 @@ | 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 | | --- | --- | --- | --- | -| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台账号细粒度权限点、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费收款、逾期标记、内部催缴台账、催缴外部通知、用量记录、公共题库披露策略第一版 | 地区/全国套餐权限细化、平台侧主题模板库、平台在线收款、平台审计报表增强和平台员工创建/授权 UI | +| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台账号细粒度权限点、平台审计日志、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、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 | diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 8749576d..42d9581c 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 页面已经可构建:工作台、租户管理、账务中心、公共题库授权;启动时应先接 `GET /api/platform-admin/permissions` 获取 `effective` 权限用于菜单和按钮可见性;租户管理页已接租户详情、账务资料编辑和最近平台审计,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV,并可查看开放审计告警、确认或解决告警,也能查看审计告警外部通知渠道、催缴外部通知渠道和最近发送事件摘要;账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、逾期预览、内部催缴生成和催缴记录查看。 +- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权、平台员工;启动时应先接 `GET /api/platform-admin/permissions` 获取 `effective` 权限用于菜单和按钮可见性;租户管理页已接租户详情、账务资料编辑和最近平台审计,平台员工页已接员工列表、搜索、创建/编辑、权限点勾选、禁用和恢复,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV,并可查看开放审计告警、确认或解决告警,也能查看审计告警外部通知渠道、催缴外部通知渠道和最近发送事件摘要;账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、逾期预览、内部催缴生成和催缴记录查看。 - 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验、背单词更细统计和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习、商城收银台、订单详情和售后入口已经有第一版页面。 - 可以按新后端主模型接入内容导航: - `content_entries` @@ -110,5 +110,6 @@ | 租户管理 | `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` | +| 平台员工 | `apps/taro/src/pages/platform-admin/staff/index.tsx` | `platform-admin/permissions`、`platform-admin/staff`、`PUT platform-admin/staff`、`PATCH platform-admin/staff/status` | -当前平台后台已经具备第一批写操作台:创建租户、租户详情查看、状态变更、账务资料维护、最近平台审计查询、最近平台审计 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、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。 +当前平台后台已经具备第一批写操作台:创建租户、租户详情查看、状态变更、账务资料维护、平台员工创建/编辑/禁用恢复、平台员工权限点勾选、最近平台审计查询、最近平台审计 CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件摘要、催缴外部通知渠道/事件摘要、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、人工收款确认、逾期预览、内部催缴生成、催缴记录查看、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限、重复开票保护和审计。平台后台的菜单和按钮必须用 `platform-admin/permissions` 返回的 `effective` 做可见性控制,但安全边界仍以后端 `PLATFORM_PERMISSION_REQUIRED` 为准。平台员工创建必须绑定 Supabase Auth 用户 ID,前端只提交公开资料和权限点,不创建密码账号、不接触 service role;禁用员工时应调用 `PATCH /api/platform-admin/staff/status` 并默认 `revokeSessions=true`。平台审计导出只开放给具备 `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。下一批继续补租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。 diff --git a/docs/refactor/implementation-status.md b/docs/refactor/implementation-status.md index 2f98d85e..72c4d7e8 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` 覆盖催缴外部通知幂等、联系方式掩码和密钥不泄露 | 平台收费和租户运营链路骨架可用,平台员工创建/授权 UI、平台在线收款和更完整平台审计报表待补 | +| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量、审计日志、催缴台账、催缴通知事件、平台权限字段和平台员工状态字段 | 不适用 | 租户管理、租户详情、账务资料维护、平台账号权限目录、平台员工列表/创建/编辑/启停、平台路由细粒度权限强校验、平台审计日志、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款确认、逾期标记、内部催缴记录、催缴外部通知渠道/事件、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台细粒度权限、平台员工创建/权限目录/JWT 访问/越权拒绝/自降级拒绝/禁用后 JWT 拒绝/审计脱敏、平台租户创建、详情、账务资料更新、状态变更、审计查询、订阅批量开票、重复保护、逾期 dry-run/处理/提醒查询、催缴通知渠道/事件脱敏、非法输入拒绝和学生越权拒绝;`npm run test:worker:platform-billing` 覆盖自动计费幂等和审计,`npm run test:worker:platform-dunning` 覆盖逾期催缴幂等和审计,`npm run test:worker:platform-dunning-notifications` 覆盖催缴外部通知幂等、联系方式掩码和密钥不泄露;Taro 类型检查覆盖平台员工管理页面 | 平台收费、租户运营和员工授权链路骨架可用,平台在线收款和更完整平台审计报表待补 | | 登录认证 | 已建短信验证码、会话、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 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 | diff --git a/docs/refactor/multitenant-auth-security-contract.md b/docs/refactor/multitenant-auth-security-contract.md index 892e0193..1f98a600 100644 --- a/docs/refactor/multitenant-auth-security-contract.md +++ b/docs/refactor/multitenant-auth-security-contract.md @@ -62,8 +62,9 @@ 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。 + - 平台管理员已支持 `platform_users.platform_permissions` 细粒度权限,`{"*":true}` 为超级管理员;接口按 `platform:staff:*`、`platform:tenant:*`、`platform:billing:*`、`platform:audit:*`、`platform:question_bank:*` 等权限点强制校验。 + - 平台员工管理已落到 `GET/PUT/PATCH /api/platform-admin/staff` 和 Taro 平台员工页;员工必须绑定 Supabase Auth 用户 ID,`platform_users.status='disabled'` 后不能再通过 Supabase JWT 映射为平台管理员,禁用时也会默认撤销迁移期 session。 + - 生产前继续补平台后台关键操作审计报表。 4. 生产配置 fail-fast - `NODE_ENV=production` 时禁止默认 `AUTH_CODE_PEPPER`。 @@ -157,6 +158,9 @@ provider event id 幂等 | 权限点 | 用途 | | --- | --- | | `platform:overview:read` | 查看平台总览 | +| `platform:staff:read` | 查看平台员工 | +| `platform:staff:write` | 创建/编辑平台员工 | +| `platform:staff:status` | 启停平台员工并撤销迁移期 session | | `platform:tenant:read` | 查看租户列表和租户详情 | | `platform:tenant:write` | 创建/编辑租户 | | `platform:tenant:status` | 变更租户状态 | diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index c59d8c63..642ce505 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -11,7 +11,7 @@ - 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章、站内通知。 - 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。 - 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。 -- 平台后台 API/worker:租户、租户详情、账务资料维护、平台审计日志查询/导出、平台审计告警规则/列表/确认/解决、审计告警外部通知渠道/事件、platform-audit-alerts worker、platform-audit-notifications worker、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件、platform-dunning-notifications worker、用量。 +- 平台后台 API/worker:租户、租户详情、账务资料维护、平台员工列表/创建/编辑/启停、平台审计日志查询/导出、平台审计告警规则/列表/确认/解决、审计告警外部通知渠道/事件、platform-audit-alerts worker、platform-audit-notifications worker、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件、platform-dunning-notifications worker、用量。 - 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核;Taro 租户营销中心已接 CRM、分佣和优惠券规则/核销报表第一版操作台。 - 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。 - 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。 @@ -190,7 +190,7 @@ - H5 和小程序共用同一套业务 API client。 - 租户通过域名、小程序配置或启动参数解析。 - 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。 -- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、站内消息中心第一版、积分任务/兑换/积分明细第一版、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、催缴外部通知渠道/事件摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、独立消息中心增强、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。 +- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、站内消息中心第一版、积分任务/兑换/积分明细第一版、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台员工列表/创建/编辑/禁用恢复、平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、催缴外部通知渠道/事件摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、独立消息中心增强、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。 ### 第一批页面 @@ -243,7 +243,7 @@ 1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。 2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、站内消息筛选/已读/归档、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、独立消息中心增强、背单词更细统计、小程序支付容器、分享场景和状态管理。 -3. 补平台后台增强:租户基础资料编辑增强、平台员工创建/授权 UI 与管理 API、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。 +3. 补平台后台增强:租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。 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/scripts/api-integration-test.js b/scripts/api-integration-test.js index 2bf2eae2..028b6eb1 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -22,6 +22,7 @@ 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_PLATFORM_STAFF_USER_ID = '00000000-0000-4000-8000-00000000a997'; 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'; @@ -1179,6 +1180,247 @@ async function testPlatformAdminPermissions() { assert.equal(paymentDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'manual service-fee payment must require billing payment permission'); } +async function testPlatformStaffManagement() { + const adminHeaders = { 'x-platform-admin-key': 'local-platform-admin-key' }; + const staffSuffix = Date.now().toString(36); + const staffEmail = `platform.staff.${staffSuffix}@example.test`; + const staffPhone = `139${String(Date.now()).slice(-8)}`; + const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL }); + try { + await pool.query( + ` + insert into auth.users ( + id, aud, role, phone, email, phone_confirmed_at, email_confirmed_at, + raw_app_meta_data, raw_user_meta_data, created_at, updated_at + ) + values ( + $1, 'authenticated', 'authenticated', $2, $3, + now(), now(), + '{"provider":"phone","providers":["phone"],"app_role":"platform_admin"}'::jsonb, + '{}'::jsonb, now(), now() + ) + on conflict (id) + do update set phone = excluded.phone, + email = excluded.email, + raw_app_meta_data = excluded.raw_app_meta_data, + updated_at = now() + `, + [AUTH_PLATFORM_STAFF_USER_ID, staffPhone, staffEmail], + ); + } finally { + await pool.end(); + } + + const created = await request('/api/platform-admin/staff', { + tenantId: false, + userId: false, + headers: adminHeaders, + method: 'PUT', + body: { + authUserId: AUTH_PLATFORM_STAFF_USER_ID, + username: 'platform_staff_operator', + email: staffEmail, + phone: staffPhone, + name: 'Platform Staff Operator', + status: 'active', + platformPermissions: { + 'platform:staff:read': true, + 'platform:overview:read': true, + 'platform:tenant:read': true, + }, + metadata: { + title: 'operations', + secretToken: 'should-not-be-returned-through-audit', + }, + }, + }); + assert.ok(created.item?.id, 'platform admin should create platform staff'); + assert.equal(created.item?.platformPermissions?.['platform:tenant:read'], true, 'platform staff response should include granted permissions'); + assert.ok(!JSON.stringify(created).includes('local-platform-admin-key'), 'platform staff response must not leak platform key'); + + const invalidPermission = await request('/api/platform-admin/staff', { + tenantId: false, + userId: false, + headers: adminHeaders, + method: 'PUT', + body: { + authUserId: AUTH_PLATFORM_STAFF_USER_ID, + username: 'invalid_platform_staff', + name: 'Invalid Platform Staff', + platformPermissions: { + 'platform:unknown:write': true, + }, + }, + expectStatus: 400, + }); + assert.equal(invalidPermission.code, 'INVALID_PLATFORM_PERMISSION', 'platform staff permissions should reject unknown permission keys'); + + const missingAuthUser = await request('/api/platform-admin/staff', { + tenantId: false, + userId: false, + headers: adminHeaders, + method: 'PUT', + body: { + authUserId: '00000000-0000-4000-8000-00000000dead', + username: 'missing_auth_platform_staff', + name: 'Missing Auth Platform Staff', + platformPermissions: { + 'platform:overview:read': true, + }, + }, + expectStatus: 404, + }); + assert.equal(missingAuthUser.code, 'AUTH_USER_NOT_FOUND', 'platform staff must bind an existing Supabase Auth user'); + + const missingAuthBinding = await request('/api/platform-admin/staff', { + tenantId: false, + userId: false, + headers: adminHeaders, + method: 'PUT', + body: { + username: 'unbound_platform_staff', + name: 'Unbound Platform Staff', + platformPermissions: { + 'platform:overview:read': true, + }, + }, + expectStatus: 400, + }); + assert.equal(missingAuthBinding.code, 'REQUIRED_FIELD', 'platform staff authUserId should be required'); + + const emptyPermissionDenied = await request('/api/platform-admin/staff', { + tenantId: false, + userId: false, + headers: adminHeaders, + method: 'PUT', + body: { + authUserId: AUTH_PLATFORM_STAFF_USER_ID, + username: 'empty_permission_platform_staff', + name: 'Empty Permission Platform Staff', + status: 'active', + platformPermissions: {}, + }, + expectStatus: 400, + }); + assert.equal(emptyPermissionDenied.code, 'PLATFORM_PERMISSION_EMPTY', 'active platform staff should require explicit permissions'); + + const staffList = await request('/api/platform-admin/staff', { + tenantId: false, + userId: false, + headers: adminHeaders, + query: { q: 'platform_staff_operator' }, + }); + assert.ok(staffList.items?.some(item => item.id === created.item.id), 'platform staff list should include created staff'); + + const staffJwt = await createSupabaseJwt(AUTH_PLATFORM_STAFF_USER_ID, { + phone: staffPhone, + appRole: 'platform_admin', + tenantId: false, + }); + const staffHeaders = { authorization: `Bearer ${staffJwt}` }; + + const staffPermissions = await request('/api/platform-admin/permissions', { + tenantId: false, + userId: false, + headers: staffHeaders, + }); + assert.equal(staffPermissions.item?.effective?.['platform:staff:read'], true, 'staff should expose its staff read permission'); + assert.equal(staffPermissions.item?.effective?.['platform:tenant:write'], false, 'staff should not expose ungranted tenant write permission'); + + const staffCanListTenants = await request('/api/platform-admin/tenants', { + tenantId: false, + userId: false, + headers: staffHeaders, + }); + assert.ok(Array.isArray(staffCanListTenants.items), 'staff should list tenants with tenant read permission'); + + const staffCreateTenantDenied = await request('/api/platform-admin/tenants', { + tenantId: false, + userId: false, + headers: staffHeaders, + method: 'POST', + body: { + slug: `staff-denied-${Date.now().toString(36)}`, + name: 'Staff Denied Tenant', + }, + expectStatus: 403, + }); + assert.equal(staffCreateTenantDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'platform staff should not create tenants without tenant write permission'); + + const staffWriteDenied = await request('/api/platform-admin/staff', { + tenantId: false, + userId: false, + headers: staffHeaders, + method: 'PUT', + body: { + username: 'staff_cannot_create_staff', + name: 'Staff Cannot Create Staff', + platformPermissions: { 'platform:overview:read': true }, + }, + expectStatus: 403, + }); + assert.equal(staffWriteDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'staff write should require explicit platform staff write permission'); + + const platformAdminJwt = await createSupabaseJwt(AUTH_PLATFORM_ADMIN_USER_ID, { + phone: '13999999999', + appRole: 'platform_admin', + tenantId: false, + }); + const platformAdminHeaders = { authorization: `Bearer ${platformAdminJwt}` }; + + const selfDowngradeDenied = await request('/api/platform-admin/staff', { + tenantId: false, + userId: false, + headers: platformAdminHeaders, + method: 'PUT', + body: { + id: '00000000-0000-0000-0000-000000000999', + authUserId: AUTH_PLATFORM_ADMIN_USER_ID, + username: 'smoke_platform_admin', + phone: '13999999999', + name: 'Smoke Platform Admin', + status: 'active', + platformPermissions: { 'platform:overview:read': true }, + }, + expectStatus: 400, + }); + assert.equal(selfDowngradeDenied.code, 'CANNOT_DOWNGRADE_SELF', 'platform admin should not remove its own super permission'); + + const disabled = await request('/api/platform-admin/staff/status', { + tenantId: false, + userId: false, + headers: adminHeaders, + method: 'PATCH', + body: { + staffId: created.item.id, + status: 'disabled', + reason: 'integration disable test', + revokeSessions: true, + }, + }); + assert.equal(disabled.item?.status, 'disabled', 'platform admin should disable platform staff'); + + const disabledStaffDenied = await request('/api/platform-admin/overview', { + tenantId: false, + userId: false, + headers: staffHeaders, + expectStatus: 403, + }); + assert.equal(disabledStaffDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'disabled platform staff JWT should no longer authenticate as platform admin'); + + const audit = await request('/api/platform-admin/audit-logs', { + tenantId: false, + userId: false, + headers: adminHeaders, + query: { targetType: 'platform_user', q: 'platform.staff', limit: 20 }, + }); + assert.ok( + audit.items?.some(item => item.targetId === created.item.id && item.action === 'platform.staff.status_updated'), + 'platform staff status changes should be audited', + ); + assert.ok(!JSON.stringify(audit).includes('should-not-be-returned-through-audit'), 'platform staff audit should not leak token-like metadata'); +} + async function testSupabaseJwksIdentity() { const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); const publicJwk = await exportJWK(publicKey); @@ -9014,6 +9256,7 @@ async function main() { await check('phone binding', testPhoneBinding); await check('Supabase JWT identity', testSupabaseJwtIdentity); await check('platform admin permissions', testPlatformAdminPermissions); + await check('platform staff management', testPlatformStaffManagement); 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/platform-audit-notification-worker-integration-test.js b/scripts/platform-audit-notification-worker-integration-test.js index 97772276..c23bc9f6 100644 --- a/scripts/platform-audit-notification-worker-integration-test.js +++ b/scripts/platform-audit-notification-worker-integration-test.js @@ -83,11 +83,44 @@ function runWorkerOnce() { } async function cleanup(pool) { + await pool.query( + ` + delete from public.platform_audit_notification_events + where channel_id in ( + select id + from public.platform_audit_notification_channels + where channel_code in ('worker_platform_audit_test', 'integration_platform_audit') + ) + or alert_id in ( + select id + from public.platform_audit_alerts + where details->>'source' in ('worker-test', 'api-integration-test') + or details->>'workerId' = 'platform-audit-alert-worker-test' + ) + `, + ); await pool.query("delete from public.platform_audit_notification_events where alert_id = $1", [ids.alert]); await pool.query("delete from public.platform_audit_notification_channels where channel_code = 'worker_platform_audit_test'"); await pool.query("delete from app_private.platform_secrets where secret_scope = 'webhook' and secret_key = 'worker_platform_audit_test'"); - await pool.query('delete from public.platform_audit_alerts where id = $1 or audit_log_id = $2', [ids.alert, ids.auditLog]); - await pool.query('delete from public.audit_logs where id = $1 or tenant_id = $2', [ids.auditLog, ids.tenant]); + await pool.query( + ` + delete from public.platform_audit_alerts + where id = $1 + or audit_log_id = $2 + or details->>'source' in ('worker-test', 'api-integration-test') + or details->>'workerId' = 'platform-audit-alert-worker-test' + `, + [ids.alert, ids.auditLog], + ); + await pool.query( + ` + delete from public.audit_logs + where id = $1 + or tenant_id = $2 + or user_agent in ('platform-audit-notification-worker-test', 'platform-audit-alert-worker-test') + `, + [ids.auditLog, ids.tenant], + ); await pool.query('delete from public.tenant_billing_profiles where tenant_id = $1', [ids.tenant]); await pool.query('delete from public.tenant_domains where tenant_id = $1', [ids.tenant]); await pool.query('delete from public.tenants where id = $1', [ids.tenant]); @@ -108,7 +141,7 @@ async function seed(pool, webhookUrl) { details, ip_address, user_agent, created_at ) values ( - $1, $2::uuid, null, 'platform.tenant.status_updated', 'tenant', $3, + $1, $2::uuid, null, 'platform.worker_test.status_updated', 'tenant', $3, '{"status":"suspended","apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb, '127.0.0.1', 'platform-audit-notification-worker-test', now() ) @@ -125,7 +158,7 @@ async function seed(pool, webhookUrl) { ) values ( $1, $2, $3, $4::uuid, 'high', 'open', - 'platform.tenant.status_updated', 'tenant', $4, + 'platform.worker_test.status_updated', 'tenant', $4, '租户状态变更告警', 'platform audit notification integration alert', '{"source":"worker-test","apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb, now(), now() @@ -153,7 +186,7 @@ async function seed(pool, webhookUrl) { values ( 'worker_platform_audit_test', 'Worker 平台审计通知', true, 'generic', $1, 'app_private.platform_secrets:webhook:worker_platform_audit_test', - 'medium', array['open']::text[], array['platform.tenant.*']::text[], 5 + 'medium', array['open']::text[], array['platform.worker_test.*']::text[], 5 ) on conflict (channel_code) do update set enabled = excluded.enabled, diff --git a/scripts/production-readiness-check.js b/scripts/production-readiness-check.js index 4557a460..ece43e3c 100644 --- a/scripts/production-readiness-check.js +++ b/scripts/production-readiness-check.js @@ -394,10 +394,47 @@ async function validateDatabase() { pass('db.provider_public_config', 'Active provider public configs do not contain secret-like keys'); } + const activePlatformAdminRows = await pool.query(` + select id, username, phone, auth_user_id + from public.platform_users + where primary_role = 'platform_admin' + and status = 'active' + order by created_at asc + limit 20 + `); + if (activePlatformAdminRows.rowCount === 0) { + block('db.platform_admin_active', 'At least one active platform admin user is required'); + } else { + pass('db.platform_admin_active', 'Active platform admin users found', { count: activePlatformAdminRows.rowCount }); + } + + const activePlatformAdminsWithoutAuth = await pool.query(` + select id, username, phone + from public.platform_users + where primary_role = 'platform_admin' + and status = 'active' + and auth_user_id is null + order by created_at asc + limit 20 + `); + if (activePlatformAdminsWithoutAuth.rowCount > 0) { + block('db.platform_admin_auth_binding', 'Active platform admin users must be bound to Supabase Auth users', { + count: activePlatformAdminsWithoutAuth.rowCount, + samples: activePlatformAdminsWithoutAuth.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_auth_binding', 'Active platform admin users are bound to Supabase Auth users'); + } + const platformAdminsWithoutPermissions = await pool.query(` select id, username, phone from public.platform_users where primary_role = 'platform_admin' + and status = 'active' and (platform_permissions is null or platform_permissions = '{}'::jsonb) order by created_at asc limit 20 @@ -415,6 +452,32 @@ async function validateDatabase() { pass('db.platform_admin_permissions', 'Platform admin users have explicit permission maps'); } + const disabledPlatformAdminsWithActiveSessions = await pool.query(` + select u.id, u.username, u.phone, count(s.id)::int as active_session_count + from public.platform_users u + join app_private.auth_sessions s on s.user_id = u.id + where u.primary_role = 'platform_admin' + and u.status = 'disabled' + and s.revoked_at is null + and s.expires_at > now() + group by u.id, u.username, u.phone + order by active_session_count desc + limit 20 + `); + if (disabledPlatformAdminsWithActiveSessions.rowCount > 0) { + block('db.platform_admin_disabled_sessions', 'Disabled platform admin users must not have active legacy sessions', { + count: disabledPlatformAdminsWithActiveSessions.rowCount, + samples: disabledPlatformAdminsWithActiveSessions.rows.map(row => ({ + id: row.id, + username: row.username, + phone: row.phone ? `${String(row.phone).slice(0, 3)}****${String(row.phone).slice(-4)}` : null, + activeSessionCount: row.active_session_count, + })), + }); + } else { + pass('db.platform_admin_disabled_sessions', 'Disabled platform admin users have no active legacy sessions'); + } + const missingAuthSecretRows = await pool.query(` select p.tenant_id, p.provider from public.tenant_auth_providers p diff --git a/supabase/migrations/202606300006_platform_staff_status.sql b/supabase/migrations/202606300006_platform_staff_status.sql new file mode 100644 index 00000000..6348a8ec --- /dev/null +++ b/supabase/migrations/202606300006_platform_staff_status.sql @@ -0,0 +1,18 @@ +alter table public.platform_users + add column if not exists status text not null default 'active'; + +do $$ +begin + if not exists (select 1 from pg_constraint where conname = 'platform_users_status_check') then + alter table public.platform_users + add constraint platform_users_status_check + check (status in ('active', 'disabled')); + end if; +end $$; + +update public.platform_users +set status = 'active' +where status is null; + +create index if not exists idx_platform_users_role_status + on public.platform_users(primary_role, status, created_at desc);