feat: add platform staff management

This commit is contained in:
Codex
2026-06-30 08:28:44 +08:00
parent 780feee4f8
commit 4f64b7aed8
21 changed files with 1205 additions and 25 deletions

View File

@@ -15,7 +15,7 @@
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。 - 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。 - 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。 - 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。
- 平台后台能力:租户管理、租户详情、账务资料维护、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、用量记录、公共题库授权。 - 平台后台能力:租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、用量记录、公共题库授权。
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 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 卡片和拼图包。 - 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 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 配置、跟进分配策略和队列。 - 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略和队列。
@@ -35,7 +35,7 @@
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。 - 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。 - 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。
- 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决和积分活动任务自动发放已可联调积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。后续还要补连续签到奖励规则、练习次数/单词掌握/模考成绩系统触发勋章、外部微信订阅消息/短信推送、积分风控报表、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。 - 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决和积分活动任务自动发放已可联调积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。后续还要补连续签到奖励规则、练习次数/单词掌握/模考成绩系统触发勋章、外部微信订阅消息/短信推送、积分风控报表、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘H5 端已用 KaTeX 渲染 `$...$``$$...$$``\(...\)``\[...\]` 公式,私有题图可用 `asset:<uuid>`/`content_asset:<uuid>` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、积分任务/兑换/积分明细和消息中心第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权,以及创建租户、租户详情、状态变更、账务资料维护、平台审计查询/CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费 worker 生成结果查看、收款、逾期预览/催缴记录、催缴外部通知渠道/事件摘要、用量和题库授权第一版写操作。 - `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘H5 端已用 KaTeX 渲染 `$...$``$$...$$``\(...\)``\[...\]` 公式,私有题图可用 `asset:<uuid>`/`content_asset:<uuid>` 资源引用走短期预览签名,已升级背单词为今日计划/单元学习/收藏练习、卡片翻转、发音、美/英音切换和本地位置恢复第一版,资料页已补齐预览/下载的短签名、水印 traceId 和强制水印容器第一版个人中心已接学习报告、14 天趋势、题型表现、最近练习、7 日答题榜当前排名、积分任务/兑换/积分明细和消息中心第一版;租户后台第一批页面已接入工作台、数据看板、学生/班级、题库内容、营销中心、财务运营和租户设置,营销中心已接 CRM、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版,财务运营已接退款状态机、官方账单任务、对账异常、差错工单和调整凭证第一版,设置页已接主题模板、草稿预览/发布、角色模板和成员绑定第一版;平台后台已接入工作台、租户管理、账务中心、公共题库授权、平台员工管理,以及创建租户、租户详情、状态变更、账务资料维护、平台员工创建/编辑/禁用恢复、权限点勾选、平台审计查询/CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件状态摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费 worker 生成结果查看、收款、逾期预览/催缴记录、催缴外部通知渠道/事件摘要、用量和题库授权第一版写操作。
- 根目录已清理为新 Supabase SaaS monorepo 编排层;旧 PocketBase/React 项目和旧构建产物仅保留在 `参考/` 目录作为迁移参考,不进入 Git 提交。 - 根目录已清理为新 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/tenants
apps/taro/src/pages/platform-admin/billing apps/taro/src/pages/platform-admin/billing
apps/taro/src/pages/platform-admin/question-banks apps/taro/src/pages/platform-admin/question-banks
apps/taro/src/pages/platform-admin/staff
``` ```
单次运行 CRM worker 单次运行 CRM worker
@@ -474,8 +475,9 @@ API 身份上下文:
- 平台账号以后端 `platform_users.primary_role='platform_admin'` 为准,不只信 JWT claim。 - 平台账号以后端 `platform_users.primary_role='platform_admin'` 为准,不只信 JWT claim。
- 平台账号通过 `platform_users.platform_permissions` 控制细粒度能力,`{"*":true}` 表示超级管理员。 - 平台账号通过 `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`,用于隐藏不可见菜单和按钮。 - 平台后台启动后可调用 `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` 只允许本地兼容,生产必须关闭。 - `x-platform-admin-key` 只允许本地兼容,生产必须关闭。
## 重要安全约定 ## 重要安全约定
@@ -522,7 +524,7 @@ git diff --check
优先继续补: 优先继续补:
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。 1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台在线收款、审计报表增强、平台员工创建/授权 UI、审计告警通知升级策略、催缴通知操作台细节和小程序兼容验证。 2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台在线收款、审计报表增强、审计告警通知升级策略、催缴通知操作台细节和小程序兼容验证。
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。 3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。 4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。 5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。

View File

@@ -56,6 +56,7 @@ export async function findUserBySessionToken(token: string) {
from app_private.auth_sessions s from app_private.auth_sessions s
join public.platform_users u on u.id = s.user_id join public.platform_users u on u.id = s.user_id
where s.token_hash = $1 where s.token_hash = $1
and u.status = 'active'
and s.revoked_at is null and s.revoked_at is null
and s.expires_at > now() and s.expires_at > now()
limit 1 limit 1
@@ -142,6 +143,7 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex
from public.platform_users u from public.platform_users u
left join public.tenant_memberships tm on tm.user_id = u.id and tm.status = 'active' left join public.tenant_memberships tm on tm.user_id = u.id and tm.status = 'active'
where u.auth_user_id = $1::uuid where u.auth_user_id = $1::uuid
and u.status = 'active'
and u.primary_role = 'platform_admin' and u.primary_role = 'platform_admin'
and ($2::uuid is null or exists ( and ($2::uuid is null or exists (
select 1 select 1
@@ -175,6 +177,7 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex
from public.platform_users u from public.platform_users u
join public.tenant_memberships tm on tm.user_id = u.id join public.tenant_memberships tm on tm.user_id = u.id
where u.auth_user_id = $1::uuid where u.auth_user_id = $1::uuid
and u.status = 'active'
and tm.status = 'active' and tm.status = 'active'
and tm.tenant_id = $2::uuid and tm.tenant_id = $2::uuid
order by case order by case

View File

@@ -19,6 +19,7 @@ import {
platformPermissionsRoute, platformPermissionsRoute,
platformPlansRoute, platformPlansRoute,
platformQuestionBanksRoute, platformQuestionBanksRoute,
platformStaffRoute,
processOverdueInvoicesRoute, processOverdueInvoicesRoute,
questionBankGrantsRoute, questionBankGrantsRoute,
recordUsageRoute, recordUsageRoute,
@@ -27,8 +28,10 @@ import {
tenantInvoicesRoute, tenantInvoicesRoute,
tenantsRoute, tenantsRoute,
tenantUsageRoute, tenantUsageRoute,
updatePlatformStaffStatusRoute,
updatePlatformAuditAlertStatusRoute, updatePlatformAuditAlertStatusRoute,
updateTenantStatusRoute, updateTenantStatusRoute,
upsertPlatformStaffRoute,
upsertPlatformAuditNotificationChannelRoute, upsertPlatformAuditNotificationChannelRoute,
upsertPlatformDunningNotificationChannelRoute, upsertPlatformDunningNotificationChannelRoute,
upsertQuestionBankGrantRoute, upsertQuestionBankGrantRoute,
@@ -37,6 +40,9 @@ import {
export const platformAdminRoutes: RouteDefinition[] = [ export const platformAdminRoutes: RouteDefinition[] = [
['GET', '/api/platform-admin/permissions', platformPermissionsRoute], ['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/overview', platformOverviewRoute],
['GET', '/api/platform-admin/plans', platformPlansRoute], ['GET', '/api/platform-admin/plans', platformPlansRoute],
['GET', '/api/platform-admin/question-banks', platformQuestionBanksRoute], ['GET', '/api/platform-admin/question-banks', platformQuestionBanksRoute],

View File

@@ -45,11 +45,15 @@ function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {}; return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
} }
function booleanValue(value: unknown, fallback = false) {
return typeof value === 'boolean' ? value : fallback;
}
function truncate(value: unknown, max = 1900) { function truncate(value: unknown, max = 1900) {
return String(value ?? '').slice(0, max); 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 TENANT_INVOICE_STATUSES = new Set(['draft', 'issued', 'paid', 'void', 'overdue']);
const PLATFORM_AUDIT_ALERT_STATUSES = new Set(['open', 'acknowledged', 'resolved', 'ignored']); 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']); 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_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_TYPES = new Set(['due_soon', 'overdue', 'final_notice', 'manual']);
const PLATFORM_DUNNING_REMINDER_CHANNELS = new Set(['manual', 'internal', 'sms', 'email', 'wechat', 'crm']); 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 = [ const PLATFORM_PERMISSION_CATALOG = [
{ key: 'platform:overview:read', group: 'overview', label: '平台概览' }, { 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:read', group: 'tenant', label: '查看租户' },
{ key: 'platform:tenant:write', group: 'tenant', label: '创建/编辑租户' }, { key: 'platform:tenant:write', group: 'tenant', label: '创建/编辑租户' },
{ key: 'platform:tenant:status', 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; const redactAuditAlertValue = redactAuditExportValue;
type PlatformStaffPublicRow = Record<string, unknown> & {
rawProfile?: unknown;
};
function platformStaffPublicRow<T extends PlatformStaffPublicRow>(item: T): T {
if (!Object.prototype.hasOwnProperty.call(item, 'rawProfile')) return item;
return {
...item,
rawProfile: redactAuditExportValue(item.rawProfile),
};
}
function contentBase64AndHash(content: string) { function contentBase64AndHash(content: string) {
const buffer = Buffer.from(content, 'utf8'); const buffer = Buffer.from(content, 'utf8');
return { return {
@@ -498,6 +518,67 @@ function grantStatusFrom(value: string) {
return status; 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<string>(PLATFORM_PERMISSION_CATALOG.map(item => item.key));
}
function normalizePlatformPermissions(value: unknown) {
const input = value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
const allowed = allowedPlatformPermissionKeys();
const output: Record<string, true> = {};
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<string, unknown>) {
return Object.keys(permissions).filter(key => permissions[key] === true).sort();
}
function platformPermissionAllowed(permissions: Record<string, unknown>, permission: string) { function platformPermissionAllowed(permissions: Record<string, unknown>, permission: string) {
if (permissions['*'] === true) return true; if (permissions['*'] === true) return true;
if (permissions[permission] === 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<PlatformStaffPublicRow>(
`
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) { export async function platformOverviewRoute(ctx: RequestContext) {
await requirePlatformAdmin(ctx, 'platform:overview:read'); await requirePlatformAdmin(ctx, 'platform:overview:read');

View File

@@ -28,6 +28,7 @@ export default defineAppConfig({
'pages/platform-admin/tenants/index', 'pages/platform-admin/tenants/index',
'pages/platform-admin/billing/index', 'pages/platform-admin/billing/index',
'pages/platform-admin/question-banks/index', 'pages/platform-admin/question-banks/index',
'pages/platform-admin/staff/index',
], ],
window: { window: {
backgroundTextStyle: 'light', backgroundTextStyle: 'light',

View File

@@ -219,6 +219,74 @@
color: #be123c; 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 { .platform-error {
display: block; display: block;
margin-top: 12px; margin-top: 12px;

View File

@@ -0,0 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '平台员工',
});

View File

@@ -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<string, true>;
};
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<string, unknown> | 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<string, string> = {
overview: '概览',
staff: '员工',
tenant: '租户',
billing: '账务',
usage: '用量',
audit: '审计',
question_bank: '公共题库',
};
return labels[group || ''] || group || '其他';
}
function fromStaff(item: PlatformStaffItem): StaffForm {
const platformPermissions: Record<string, true> = {};
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<PlatformStaffItem[]>([]);
const [permissionSummary, setPermissionSummary] = useState<PlatformPermissionSummary | null>(null);
const [form, setForm] = useState<StaffForm>(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<string, PlatformPermissionCatalogItem[]>();
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}?后端会默认撤销迁移期 sessionSupabase 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 (
<View className='platform-page'>
<View className='platform-shell'>
<View className='platform-header'>
<Text className='platform-kicker'>Staff</Text>
<Text className='platform-title'></Text>
<Text className='platform-subtitle'> Supabase Auth </Text>
</View>
<View className='platform-actions'>
<Input className='platform-input' placeholder='姓名、用户名、手机号、邮箱' value={keyword} onInput={event => setKeyword(String(event.detail.value || ''))} />
<Button className='platform-button primary' onClick={() => reload(status, keyword)}></Button>
<Button className='platform-button' onClick={resetForm}></Button>
</View>
<View className='platform-tabs'>
{[
{ label: '全部', value: '' },
{ label: 'active', value: 'active' },
{ label: 'disabled', value: 'disabled' },
].map(item => (
<Button key={item.label} className={`platform-button ${status === item.value ? 'active' : ''}`} onClick={() => chooseStatus(item.value)}>{item.label}</Button>
))}
</View>
<View className='platform-grid'>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(staff.length)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(activeCount)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(disabledCount)}</Text></View>
<View className='platform-metric'><Text className='platform-metric-label'></Text><Text className='platform-metric-value'>{String(selectedPermissions.length)}</Text></View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'>{form.id ? '编辑平台员工' : '创建平台员工'}</Text>
<View className='platform-form'>
<View className='platform-field wide'><Text className='platform-field-label'> ID</Text><Input className='platform-input' placeholder='编辑已有员工时自动填充' value={form.id} onInput={event => updateForm('id', String(event.detail.value || ''))} /></View>
<View className='platform-field wide'><Text className='platform-field-label'>Supabase Auth ID</Text><Input className='platform-input' placeholder='auth.users.id' value={form.authUserId} onInput={event => updateForm('authUserId', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='platform_operator' value={form.username} onInput={event => updateForm('username', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='员工姓名' value={form.name} onInput={event => updateForm('name', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='name@example.com' value={form.email} onInput={event => updateForm('email', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='13800138000' value={form.phone} onInput={event => updateForm('phone', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'></Text><Input className='platform-input' placeholder='active / disabled' value={form.status} onInput={event => updateForm('status', String(event.detail.value || ''))} /></View>
<View className='platform-field'><Text className='platform-field-label'> URL</Text><Input className='platform-input' placeholder='可选' value={form.avatarUrl} onInput={event => updateForm('avatarUrl', String(event.detail.value || ''))} /></View>
</View>
<View className='platform-permission-panel'>
<View className='platform-permission-header'>
<Text className='platform-row-main'></Text>
<Button className={`platform-mini-button ${form.platformPermissions['*'] ? 'active' : ''}`} onClick={toggleSuperPermission}> *</Button>
</View>
{groupedCatalog.map(group => (
<View className='platform-permission-group' key={group.group}>
<Text className='platform-field-label'>{groupLabel(group.group)}</Text>
<View className='platform-chip-list'>
{group.items.map(item => (
<Button
key={item.key}
className={`platform-chip ${form.platformPermissions[item.key] ? 'active' : ''}`}
onClick={() => togglePermission(item.key)}
>
{permissionLabel(item)}
</Button>
))}
</View>
</View>
))}
{!catalog.length ? <View className='platform-empty'></View> : null}
</View>
<View className='platform-actions'>
<Button className='platform-button primary' loading={busy === 'save'} disabled={!canWrite} onClick={submitStaff}></Button>
<Button className='platform-button' onClick={resetForm}></Button>
</View>
</View>
<View className='platform-section'>
<Text className='platform-section-title'></Text>
<View className='platform-list'>
{staff.map(item => {
const keys = permissionKeys(item.platformPermissions);
return (
<View className='platform-row' key={item.id}>
<Text className='platform-row-main'>{item.name || item.username || item.id}</Text>
<Text className='platform-row-meta'>{item.username || '-'} · {item.status || '-'} · {item.email || '-'} · {item.phone || '-'}</Text>
<Text className='platform-row-meta'>Auth {item.authUserId || '未绑定'} · {dateText(item.lastSeenAt)} · {dateText(item.createdAt)}</Text>
<View className='platform-chip-list compact'>
{keys.slice(0, 10).map(key => <Text className='platform-chip readonly' key={key}>{key}</Text>)}
{keys.length > 10 ? <Text className='platform-chip readonly'>+{keys.length - 10}</Text> : null}
{!keys.length ? <Text className='platform-chip readonly'></Text> : null}
</View>
<View className='platform-row-actions'>
<Button className='platform-mini-button' onClick={() => editStaff(item)}></Button>
{item.status === 'disabled' ? (
<Button className='platform-mini-button' disabled={!canChangeStatus} loading={busy === `status-${item.id}-active`} onClick={() => submitStatus(item, 'active')}></Button>
) : (
<Button className='platform-mini-button danger' disabled={!canChangeStatus} loading={busy === `status-${item.id}-disabled`} onClick={() => submitStatus(item, 'disabled')}></Button>
)}
</View>
</View>
);
})}
</View>
{!staff.length ? <View className='platform-empty'></View> : null}
</View>
{error ? <Text className='platform-error'>{error}</Text> : null}
</View>
</View>
);
}

View File

@@ -91,6 +91,7 @@ export default function PlatformWorkbenchPage() {
{ name: '租户管理', path: '/pages/platform-admin/tenants/index', meta: '租户状态、套餐、欠费和到期' }, { name: '租户管理', path: '/pages/platform-admin/tenants/index', meta: '租户状态、套餐、欠费和到期' },
{ name: '账务中心', path: '/pages/platform-admin/billing/index', meta: 'SaaS 套餐、发票、收款、用量' }, { name: '账务中心', path: '/pages/platform-admin/billing/index', meta: 'SaaS 套餐、发票、收款、用量' },
{ name: '公共题库', path: '/pages/platform-admin/question-banks/index', meta: '地区题库、授权、披露范围' }, { name: '公共题库', path: '/pages/platform-admin/question-banks/index', meta: '地区题库、授权、披露范围' },
{ name: '平台员工', path: '/pages/platform-admin/staff/index', meta: '员工账号、平台权限、禁用恢复' },
]; ];
async function exportAuditLogs() { async function exportAuditLogs() {
@@ -161,6 +162,7 @@ export default function PlatformWorkbenchPage() {
<Button className='platform-button primary' onClick={() => Taro.navigateTo({ url: '/pages/platform-admin/tenants/index' })}></Button> <Button className='platform-button primary' onClick={() => Taro.navigateTo({ url: '/pages/platform-admin/tenants/index' })}></Button>
<Button className='platform-button' onClick={() => Taro.navigateTo({ url: '/pages/platform-admin/billing/index' })}></Button> <Button className='platform-button' onClick={() => Taro.navigateTo({ url: '/pages/platform-admin/billing/index' })}></Button>
<Button className='platform-button' onClick={() => Taro.navigateTo({ url: '/pages/platform-admin/question-banks/index' })}></Button> <Button className='platform-button' onClick={() => Taro.navigateTo({ url: '/pages/platform-admin/question-banks/index' })}></Button>
<Button className='platform-button' onClick={() => Taro.navigateTo({ url: '/pages/platform-admin/staff/index' })}></Button>
</View> </View>
<View className='platform-section'> <View className='platform-section'>

View File

@@ -38,6 +38,23 @@ export interface PlatformPermissionSummary {
catalog?: PlatformPermissionCatalogItem[]; 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<string, unknown> | null;
rawProfile?: Record<string, unknown> | null;
lastSeenAt?: string | null;
createdAt?: string | null;
updatedAt?: string | null;
}
export interface PlatformSaasPlan { export interface PlatformSaasPlan {
id: string; id: string;
code: string; code: string;
@@ -478,6 +495,26 @@ export interface UpsertPlatformQuestionBankGrantInput {
expiresAt?: string; expiresAt?: string;
} }
export interface UpsertPlatformStaffInput {
id?: string;
authUserId?: string;
username?: string;
email?: string;
phone?: string;
name: string;
avatarUrl?: string;
status?: string;
platformPermissions?: Record<string, unknown>;
metadata?: Record<string, unknown>;
}
export interface UpdatePlatformStaffStatusInput {
staffId: string;
status: 'active' | 'disabled';
reason?: string;
revokeSessions?: boolean;
}
export async function loadPlatformOverview() { export async function loadPlatformOverview() {
return apiRequest<{ item?: PlatformOverview }>('/api/platform-admin/overview', { tenantId: null }); 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 }); 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) { export async function loadPlatformPlans(includeArchived = false) {
return apiRequest<{ items?: PlatformSaasPlan[] }>('/api/platform-admin/plans', { return apiRequest<{ items?: PlatformSaasPlan[] }>('/api/platform-admin/plans', {
query: { includeArchived }, query: { includeArchived },

View File

@@ -41,7 +41,7 @@
| 微信小程序登录 | 可联调 | `/api/auth/oauth/wechat-miniapp` 已接 `code2Session`、openid/unionid 身份、session 签发和登录审计 | | 微信小程序登录 | 可联调 | `/api/auth/oauth/wechat-miniapp` 已接 `code2Session`、openid/unionid 身份、session 签发和登录审计 |
| 手机号绑定/换绑 | 可联调 | `/api/auth/phone/bind` 使用 `bind_phone` 短信验证码,后端校验当前登录态、手机号唯一性、移除旧手机号 identity并撤销其它迁移期 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 主链路;生产前需真实开放平台账号和回调域名联调 | | 微信网页/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_memberships.role + permissions + role_template_id`,接口有权限点校验 |
| 自定义角色模板 | 可联调 | `tenant_role_templates` + `/api/tenant-admin/role-templates`支持权限、菜单、模块、字段、数据范围配置Taro 租户设置页已接创建、编辑、停用、权限点、菜单、模块、字段和基础数据范围配置第一版 | | 自定义角色模板 | 可联调 | `tenant_role_templates` + `/api/tenant-admin/role-templates`支持权限、菜单、模块、字段、数据范围配置Taro 租户设置页已接创建、编辑、停用、权限点、菜单、模块、字段和基础数据范围配置第一版 |
| 班级/教师/学生范围权限 | 可联调 | `tenant_classes``tenant_class_members` + `/api/tenant-admin/classes``classes/members``students``teachers`;教师默认只看自己负责班级,字段权限可脱敏学生手机号 | | 班级/教师/学生范围权限 | 可联调 | `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/classes``classes/members``students``teachers`,支持班级范围权限和审计 |
| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert``students/status``classes/members/bulk-assign``students/notes``students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 | | 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert``students/status``classes/members/bulk-assign``students/notes``students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 |
| 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 | | 用户站内通知查看 | 可联调 | `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 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 | | 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks``question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 | | 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks``question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 |
| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks``public-question-banks/adopt``public-question-banks/sync``public-question-banks/conflicts``public-question-banks/conflicts/resolve``public-question-banks/conflicts/resolve-batch``tenant-content/notifications`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,新增/更新和冲突会生成租户内容通知;租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会重新校验授权并写入逐条审计,冲突全部处理后相关通知自动 resolved | | 租户采纳/同步公共题库 | 可联调 | `/api/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 |

View File

@@ -21,7 +21,7 @@
| 模块 | 当前状态 | 已经具备 | 上线前还要补 | | 模块 | 当前状态 | 已经具备 | 上线前还要补 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收;`npm run smoke:auth:remote` 已提供真实云端 Supabase access token 回归脚本 | 真实云端 Auth/JWKS 回归需要在预生产/生产环境执行并留档,生产 RLS 深测继续执行 | | 多租户底座 | 可联调 | 租户、域名、品牌、设置、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 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 | | 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 | | 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 |
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 | | 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、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 执行验收、抽样校验和导入性能压测 | | 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 |
| 数据看板 | 可联调 | 租户 dashboard 聚合接口收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 | | 数据看板 | 可联调 | 租户 dashboard 聚合接口收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
| AI 择校推荐 | 可联调 | `ai_recommendation_reports`、SVIP 门禁、学生输入 schema、地区/分数线上下文、`local_rules` 稳定 JSON、报告列表/详情和 Taro 学生端基础页 | 真实 AI provider、prompt 版本管理、租户后台配置、报告 PDF 渲染和人工复核流程 | | 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 级水印。 - 对象存储:上传/下载签名已接入阿里云 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` 的必需集合和关键字段覆盖率通过,再跑迁移和校验报告。 - 真实数据 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 容器部署说明。 - 生产环境配置:`.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商用收费和运营能力 ### P1商用收费和运营能力

View File

@@ -15,7 +15,7 @@
| 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 | | 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台账号细粒度权限点、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费收款、逾期标记、内部催缴台账、催缴外部通知、用量记录、公共题库披露策略第一版 | 地区/全国套餐权限细化、平台侧主题模板库、平台在线收款平台审计报表增强和平台员工创建/授权 UI | | 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台账号细粒度权限点、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费收款、逾期标记、内部催缴台账、催缴外部通知、用量记录、公共题库披露策略第一版 | 地区/全国套餐权限细化、平台侧主题模板库、平台在线收款平台审计报表增强 |
| 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 | | 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 |
| 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student权限矩阵成员启停角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 | | 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student权限矩阵成员启停角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 |
| 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker、同步通知和冲突查询 API | 字段映射 UI、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 | | 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker、同步通知和冲突查询 API | 字段映射 UI、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 |

View File

@@ -31,7 +31,7 @@
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。 - `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。 - 租户后台第一批 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 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习、商城收银台、订单详情和售后入口已经有第一版页面。 - 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验、背单词更细统计和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习、商城收银台、订单详情和售后入口已经有第一版页面。
- 可以按新后端主模型接入内容导航: - 可以按新后端主模型接入内容导航:
- `content_entries` - `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/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/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/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。下一批继续补租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。

View File

@@ -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部分功能只有数据模型和导入映射部分功能还没有前端/自动化测试闭环。 但这还不是完整商用交付状态,也不能说旧项目核心功能已经全部重构完成。现在更准确的状态是:后端商用架构骨架已经立住,核心业务正在按模块补齐。部分功能已经有可调用 API部分功能只有数据模型和导入映射部分功能还没有前端/自动化测试闭环。
@@ -37,7 +37,7 @@
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务自动发放、积分任务领取、积分兑换、优惠券兑换履约和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换和用户通知查看第一版;连续签到奖励配置、练习/单词/模考触发勋章、营销自动化、积分风控报表、外部订阅消息/短信和更完整活动效果看板继续补 | | 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务自动发放、积分任务领取、积分兑换、优惠券兑换履约和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换和用户通知查看第一版;连续签到奖励配置、练习/单词/模考触发勋章、营销自动化、积分风控报表、外部订阅消息/短信和更完整活动效果看板继续补 |
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 | | 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI | | 租户后台 | 已建品牌、域名、设置、支付账户、登录 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 生产账号和回调域名联调待补 | | 登录认证 | 已建短信验证码、会话、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、导入回归和抽样验收 | | 数据导入 | 已建立 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 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 | | 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 |

View File

@@ -62,8 +62,9 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小
3. 平台管理员鉴权 3. 平台管理员鉴权
- `x-platform-admin-key` 已可通过 `ALLOW_PLATFORM_ADMIN_KEY=false` 禁用。 - `x-platform-admin-key` 已可通过 `ALLOW_PLATFORM_ADMIN_KEY=false` 禁用。
- 已支持平台管理员 Supabase JWT且以后端 `platform_users.primary_role='platform_admin'` 为准,不只信 JWT claim。 - 已支持平台管理员 Supabase JWT且以后端 `platform_users.primary_role='platform_admin'` 为准,不只信 JWT claim。
- 平台管理员已支持 `platform_users.platform_permissions` 细粒度权限,`{"*":true}` 为超级管理员;接口按 `platform:tenant:*`、`platform:billing:*`、`platform:audit:*`、`platform:question_bank:*` 等权限点强制校验。 - 平台管理员已支持 `platform_users.platform_permissions` 细粒度权限,`{"*":true}` 为超级管理员;接口按 `platform:staff:*`、`platform:tenant:*`、`platform:billing:*`、`platform:audit:*`、`platform:question_bank:*` 等权限点强制校验。
- 生产前继续补平台后台关键操作审计报表和平台员工创建/授权 UI - 平台员工管理已落到 `GET/PUT/PATCH /api/platform-admin/staff` 和 Taro 平台员工页;员工必须绑定 Supabase Auth 用户 ID`platform_users.status='disabled'` 后不能再通过 Supabase JWT 映射为平台管理员,禁用时也会默认撤销迁移期 session
- 生产前继续补平台后台关键操作审计报表。
4. 生产配置 fail-fast 4. 生产配置 fail-fast
- `NODE_ENV=production` 时禁止默认 `AUTH_CODE_PEPPER`。 - `NODE_ENV=production` 时禁止默认 `AUTH_CODE_PEPPER`。
@@ -157,6 +158,9 @@ provider event id 幂等
| 权限点 | 用途 | | 权限点 | 用途 |
| --- | --- | | --- | --- |
| `platform:overview:read` | 查看平台总览 | | `platform:overview:read` | 查看平台总览 |
| `platform:staff:read` | 查看平台员工 |
| `platform:staff:write` | 创建/编辑平台员工 |
| `platform:staff:status` | 启停平台员工并撤销迁移期 session |
| `platform:tenant:read` | 查看租户列表和租户详情 | | `platform:tenant:read` | 查看租户列表和租户详情 |
| `platform:tenant:write` | 创建/编辑租户 | | `platform:tenant:write` | 创建/编辑租户 |
| `platform:tenant:status` | 变更租户状态 | | `platform:tenant:status` | 变更租户状态 |

View File

@@ -11,7 +11,7 @@
- 学生端核心 API题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章、站内通知。 - 学生端核心 API题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章、站内通知。
- 租户后台 API品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。 - 租户后台 API品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。
- 租户主题系统平台默认经典蓝、专注绿、高对比三套模板租户可保存草稿、发布主题公开租户解析只返回已发布主题Taro 租户设置页已接第一版主题操作台。 - 租户主题系统平台默认经典蓝、专注绿、高对比三套模板租户可保存草稿、发布主题公开租户解析只返回已发布主题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、分佣和优惠券规则/核销报表第一版操作台。 - 销售/代理/CRM 增长链路邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核Taro 租户营销中心已接 CRM、分佣和优惠券规则/核销报表第一版操作台。
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。 - 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。 - 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
@@ -190,7 +190,7 @@
- H5 和小程序共用同一套业务 API client。 - H5 和小程序共用同一套业务 API client。
- 租户通过域名、小程序配置或启动参数解析。 - 租户通过域名、小程序配置或启动参数解析。
- 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。 - 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/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 和发票。 1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、站内消息筛选/已读/归档、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、独立消息中心增强、背单词更细统计、小程序支付容器、分享场景和状态管理。 2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、站内消息筛选/已读/归档、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、独立消息中心增强、背单词更细统计、小程序支付容器、分享场景和状态管理。
3. 补平台后台增强:租户基础资料编辑增强、平台员工创建/授权 UI 与管理 API、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。 3. 补平台后台增强:租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。
4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。 4. 云服务器部署 Supabase/PostgreSQL 和 API配置对象存储生产环境变量`check:refactor` 的远程等价测试。
5. 导出现有 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 做 production dry-run、导入演练、校验和抽样验收。 5. 导出现有 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 做 production dry-run、导入演练、校验和抽样验收。
6. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。 6. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。

View File

@@ -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_TENANT_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a102';
const AUTH_PLATFORM_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a999'; 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_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 AUTH_JWT_SECRET = 'development-jwt-secret-change-me';
const START_SERVER = process.argv.includes('--start-server'); const START_SERVER = process.argv.includes('--start-server');
const ENABLE_REAL_STORAGE_SIGN_TESTS = process.env.ENABLE_REAL_STORAGE_SIGN_TESTS === 'true'; 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'); 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() { async function testSupabaseJwksIdentity() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const publicJwk = await exportJWK(publicKey); const publicJwk = await exportJWK(publicKey);
@@ -9014,6 +9256,7 @@ async function main() {
await check('phone binding', testPhoneBinding); await check('phone binding', testPhoneBinding);
await check('Supabase JWT identity', testSupabaseJwtIdentity); await check('Supabase JWT identity', testSupabaseJwtIdentity);
await check('platform admin permissions', testPlatformAdminPermissions); await check('platform admin permissions', testPlatformAdminPermissions);
await check('platform staff management', testPlatformStaffManagement);
await check('Supabase JWKS JWT identity', testSupabaseJwksIdentity); await check('Supabase JWKS JWT identity', testSupabaseJwksIdentity);
await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled); await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled);
await check('platform tenant operations and audit', testPlatformTenantOperationsAndAudit); await check('platform tenant operations and audit', testPlatformTenantOperationsAndAudit);

View File

@@ -83,11 +83,44 @@ function runWorkerOnce() {
} }
async function cleanup(pool) { 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_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 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 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(
await pool.query('delete from public.audit_logs where id = $1 or tenant_id = $2', [ids.auditLog, ids.tenant]); `
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_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.tenant_domains where tenant_id = $1', [ids.tenant]);
await pool.query('delete from public.tenants where 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 details, ip_address, user_agent, created_at
) )
values ( 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, '{"status":"suspended","apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb,
'127.0.0.1', 'platform-audit-notification-worker-test', now() '127.0.0.1', 'platform-audit-notification-worker-test', now()
) )
@@ -125,7 +158,7 @@ async function seed(pool, webhookUrl) {
) )
values ( values (
$1, $2, $3, $4::uuid, 'high', 'open', $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', '租户状态变更告警', 'platform audit notification integration alert',
'{"source":"worker-test","apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb, '{"source":"worker-test","apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb,
now(), now() now(), now()
@@ -153,7 +186,7 @@ async function seed(pool, webhookUrl) {
values ( values (
'worker_platform_audit_test', 'Worker 平台审计通知', true, 'generic', 'worker_platform_audit_test', 'Worker 平台审计通知', true, 'generic',
$1, 'app_private.platform_secrets:webhook:worker_platform_audit_test', $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) on conflict (channel_code)
do update set enabled = excluded.enabled, do update set enabled = excluded.enabled,

View File

@@ -394,10 +394,47 @@ async function validateDatabase() {
pass('db.provider_public_config', 'Active provider public configs do not contain secret-like keys'); 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(` const platformAdminsWithoutPermissions = await pool.query(`
select id, username, phone select id, username, phone
from public.platform_users from public.platform_users
where primary_role = 'platform_admin' where primary_role = 'platform_admin'
and status = 'active'
and (platform_permissions is null or platform_permissions = '{}'::jsonb) and (platform_permissions is null or platform_permissions = '{}'::jsonb)
order by created_at asc order by created_at asc
limit 20 limit 20
@@ -415,6 +452,32 @@ async function validateDatabase() {
pass('db.platform_admin_permissions', 'Platform admin users have explicit permission maps'); 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(` const missingAuthSecretRows = await pool.query(`
select p.tenant_id, p.provider select p.tenant_id, p.provider
from public.tenant_auth_providers p from public.tenant_auth_providers p

View File

@@ -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);