forked from wangziqi/gongxue-base
feat: add platform staff management
This commit is contained in:
10
README.md
10
README.md
@@ -15,7 +15,7 @@
|
||||
- 租户后台能力:品牌、主题模板/草稿/发布、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券规则/核销报表、勋章管理/手动发放/签到积分反馈自动发放、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。
|
||||
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入。
|
||||
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词卡片学习/发音/收藏练习、个人中心、站内通知、勋章、考试倒计时、签到积分、积分活动任务、积分兑换、题目反馈、排行榜、分数线、AI 择校推荐、题目视频、订单详情/状态轮询、优惠券领取/抵扣、权益、激活码预检查/兑换、资料下载;签到、积分阈值、反馈解决和积分活动可返回自动获得勋章结果,反馈处理/奖励、勋章发放和积分兑换会写入用户站内通知。
|
||||
- 平台后台能力:租户管理、租户详情、账务资料维护、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、用量记录、公共题库授权。
|
||||
- 平台后台能力:租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台细粒度权限点、平台审计日志查询和 CSV/JSON 导出、平台审计告警规则/开放告警查询/确认/解决、平台审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、账单、服务费收款、逾期标记、内部催缴台账、平台催缴外部通知渠道和发送事件、用量记录、公共题库授权。
|
||||
- 公共题库商业化能力:租户可采纳平台授权题库为本租户副本,并可手动或由 worker 自动同步平台新增/更新题目;同步会保护租户自改题目,返回冲突而不覆盖,后台可查询冲突明细。
|
||||
- 题库导出能力:租户内容编辑可按题目集合、内容入口或分类节点导出 JSON、`paper_json`、打印 payload、PDF、Word 和每日一练图片 ZIP 素材包,后端强制租户隔离、答案/解析开关、复合题子题脱敏、导出 job 和审计;PDF/Word/ZIP 由 exports worker 生成水印文件或运营素材并发布到 `content_assets`;`daily_practice` 支持每日一练九宫格 metadata、PDF/Word 版式、9 张 PNG/SVG 卡片和拼图包。
|
||||
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置、跟进分配策略和队列。
|
||||
@@ -35,7 +35,7 @@
|
||||
- 题库导出已完成服务端结构化 payload、PDF/Word 二进制 worker、每日一练基础导出和每日一练 ZIP 图片素材包;后续还要补更精细试卷模板、多模板排版和导出操作台体验。
|
||||
- 优惠券复杂规则和核销报表已可联调,包含状态启停、活动分组、最低订单金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、核销明细和活动报表;Taro 租户营销中心已接优惠券规则表单、筛选、核销明细和报表第一版。
|
||||
- 勋章管理、手动发放、签到连续天数、积分阈值、反馈解决和积分活动任务自动发放已可联调;积分活动任务、积分兑换商品、兑换订单、优惠券兑换履约、租户后台配置和用户站内通知第一版已完成,Taro 学生个人中心已接积分任务/兑换/积分明细和消息中心第一版,租户营销中心已接积分任务/兑换操作台和用户通知查看第一版。后续还要补连续签到奖励规则、练习次数/单词掌握/模考成绩系统触发勋章、外部微信订阅消息/短信推送、积分风控报表、分佣真实打款 provider、发票、批量凭证上传、CRM 富卡片模板、失败告警、死信运营台、销售转化看板、公共题库版本通知和冲突处理操作台。
|
||||
- `apps/taro` 已建立 Taro 4 React 跨端前端地基,包含 H5 学生端、租户后台、平台后台三套构建入口、租户解析、统一 API client 和 Supabase Auth client 初始化;学生端第一批页面已接入登录、首页、题库、练习、背单词、知识手册、分数线、AI 择校推荐、资料和个人中心,已新增 `RichContent` 安全渲染组件用于题干、选项、解析、知识手册和逐题复盘,H5 端已用 KaTeX 渲染 `$...$`、`$$...$$`、`\(...\)`、`\[...\]` 公式,私有题图可用 `asset:<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 提交。
|
||||
|
||||
更完整的进度看这些文档:
|
||||
@@ -168,6 +168,7 @@ apps/taro/src/pages/platform-admin/workbench
|
||||
apps/taro/src/pages/platform-admin/tenants
|
||||
apps/taro/src/pages/platform-admin/billing
|
||||
apps/taro/src/pages/platform-admin/question-banks
|
||||
apps/taro/src/pages/platform-admin/staff
|
||||
```
|
||||
|
||||
单次运行 CRM worker:
|
||||
@@ -474,8 +475,9 @@ API 身份上下文:
|
||||
|
||||
- 平台账号以后端 `platform_users.primary_role='platform_admin'` 为准,不只信 JWT claim。
|
||||
- 平台账号通过 `platform_users.platform_permissions` 控制细粒度能力,`{"*":true}` 表示超级管理员。
|
||||
- 平台员工通过 `GET/PUT/PATCH /api/platform-admin/staff` 管理,必须绑定 Supabase Auth 用户 ID;禁用员工会使 `status='disabled'`,后续 Supabase JWT 映射和迁移期 session 都会被拒绝。
|
||||
- 平台后台启动后可调用 `GET /api/platform-admin/permissions` 获取 `catalog/effective`,用于隐藏不可见菜单和按钮。
|
||||
- 后端接口继续按 `platform:tenant:read/write/status/billing_profile`、`platform:billing:read/write/payment/dunning/notification`、`platform:audit:read/export/alert/notification`、`platform:question_bank:read/grant` 等权限点强制校验。
|
||||
- 后端接口继续按 `platform:staff:read/write/status`、`platform:tenant:read/write/status/billing_profile`、`platform:billing:read/write/payment/dunning/notification`、`platform:audit:read/export/alert/notification`、`platform:question_bank:read/grant` 等权限点强制校验。
|
||||
- `x-platform-admin-key` 只允许本地兼容,生产必须关闭。
|
||||
|
||||
## 重要安全约定
|
||||
@@ -522,7 +524,7 @@ git diff --check
|
||||
优先继续补:
|
||||
|
||||
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
|
||||
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台在线收款、审计报表增强、平台员工创建/授权 UI、审计告警通知升级策略、催缴通知操作台细节和小程序兼容验证。
|
||||
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、独立消息中心增强、背单词更细统计、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台在线收款、审计报表增强、审计告警通知升级策略、催缴通知操作台细节和小程序兼容验证。
|
||||
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
|
||||
4. 题库导出模板精排、导出操作台、真实数据 dry-run、导入字段映射 UI 和复检结果操作台。
|
||||
5. 真实 OAuth/短信/支付生产账号联调、真实生产账单抽样验收、真实打款 provider、发票、公共题库版本通知/冲突处理操作台、积分活动风控和连续签到奖励深化,以及排行榜防刷/预聚合。
|
||||
|
||||
@@ -56,6 +56,7 @@ export async function findUserBySessionToken(token: string) {
|
||||
from app_private.auth_sessions s
|
||||
join public.platform_users u on u.id = s.user_id
|
||||
where s.token_hash = $1
|
||||
and u.status = 'active'
|
||||
and s.revoked_at is null
|
||||
and s.expires_at > now()
|
||||
limit 1
|
||||
@@ -142,6 +143,7 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex
|
||||
from public.platform_users u
|
||||
left join public.tenant_memberships tm on tm.user_id = u.id and tm.status = 'active'
|
||||
where u.auth_user_id = $1::uuid
|
||||
and u.status = 'active'
|
||||
and u.primary_role = 'platform_admin'
|
||||
and ($2::uuid is null or exists (
|
||||
select 1
|
||||
@@ -175,6 +177,7 @@ export async function findUserBySupabaseJwt(token: string, requestedTenantContex
|
||||
from public.platform_users u
|
||||
join public.tenant_memberships tm on tm.user_id = u.id
|
||||
where u.auth_user_id = $1::uuid
|
||||
and u.status = 'active'
|
||||
and tm.status = 'active'
|
||||
and tm.tenant_id = $2::uuid
|
||||
order by case
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
platformPermissionsRoute,
|
||||
platformPlansRoute,
|
||||
platformQuestionBanksRoute,
|
||||
platformStaffRoute,
|
||||
processOverdueInvoicesRoute,
|
||||
questionBankGrantsRoute,
|
||||
recordUsageRoute,
|
||||
@@ -27,8 +28,10 @@ import {
|
||||
tenantInvoicesRoute,
|
||||
tenantsRoute,
|
||||
tenantUsageRoute,
|
||||
updatePlatformStaffStatusRoute,
|
||||
updatePlatformAuditAlertStatusRoute,
|
||||
updateTenantStatusRoute,
|
||||
upsertPlatformStaffRoute,
|
||||
upsertPlatformAuditNotificationChannelRoute,
|
||||
upsertPlatformDunningNotificationChannelRoute,
|
||||
upsertQuestionBankGrantRoute,
|
||||
@@ -37,6 +40,9 @@ import {
|
||||
|
||||
export const platformAdminRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/platform-admin/permissions', platformPermissionsRoute],
|
||||
['GET', '/api/platform-admin/staff', platformStaffRoute],
|
||||
['PUT', '/api/platform-admin/staff', upsertPlatformStaffRoute],
|
||||
['PATCH', '/api/platform-admin/staff/status', updatePlatformStaffStatusRoute],
|
||||
['GET', '/api/platform-admin/overview', platformOverviewRoute],
|
||||
['GET', '/api/platform-admin/plans', platformPlansRoute],
|
||||
['GET', '/api/platform-admin/question-banks', platformQuestionBanksRoute],
|
||||
|
||||
@@ -45,11 +45,15 @@ function objectValue(value: unknown): 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) {
|
||||
return String(value ?? '').slice(0, max);
|
||||
}
|
||||
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const TENANT_INVOICE_STATUSES = new Set(['draft', 'issued', 'paid', 'void', 'overdue']);
|
||||
const PLATFORM_AUDIT_ALERT_STATUSES = new Set(['open', 'acknowledged', 'resolved', 'ignored']);
|
||||
const PLATFORM_AUDIT_NOTIFICATION_EVENT_STATUSES = new Set(['pending', 'processing', 'sent', 'retrying', 'failed', 'discarded']);
|
||||
@@ -57,9 +61,13 @@ const PLATFORM_AUDIT_NOTIFICATION_PROVIDERS = new Set(['generic', 'dingtalk', 'f
|
||||
const PLATFORM_AUDIT_SEVERITIES = new Set(['low', 'medium', 'high', 'critical']);
|
||||
const PLATFORM_DUNNING_REMINDER_TYPES = new Set(['due_soon', 'overdue', 'final_notice', 'manual']);
|
||||
const PLATFORM_DUNNING_REMINDER_CHANNELS = new Set(['manual', 'internal', 'sms', 'email', 'wechat', 'crm']);
|
||||
const PLATFORM_STAFF_STATUSES = new Set(['active', 'disabled']);
|
||||
|
||||
const PLATFORM_PERMISSION_CATALOG = [
|
||||
{ key: 'platform:overview:read', group: 'overview', label: '平台概览' },
|
||||
{ key: 'platform:staff:read', group: 'staff', label: '查看平台员工' },
|
||||
{ key: 'platform:staff:write', group: 'staff', label: '创建/编辑平台员工' },
|
||||
{ key: 'platform:staff:status', group: 'staff', label: '启停平台员工' },
|
||||
{ key: 'platform:tenant:read', group: 'tenant', label: '查看租户' },
|
||||
{ key: 'platform:tenant:write', group: 'tenant', label: '创建/编辑租户' },
|
||||
{ key: 'platform:tenant:status', group: 'tenant', label: '变更租户状态' },
|
||||
@@ -113,6 +121,18 @@ function redactAuditExportValue(value: unknown, parentKey = '', depth = 0): unkn
|
||||
|
||||
const redactAuditAlertValue = redactAuditExportValue;
|
||||
|
||||
type PlatformStaffPublicRow = Record<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) {
|
||||
const buffer = Buffer.from(content, 'utf8');
|
||||
return {
|
||||
@@ -498,6 +518,67 @@ function grantStatusFrom(value: string) {
|
||||
return status;
|
||||
}
|
||||
|
||||
function platformStaffStatusFrom(value: string, fallback = 'active') {
|
||||
const status = value || fallback;
|
||||
if (!PLATFORM_STAFF_STATUSES.has(status)) {
|
||||
throw new HttpError(400, 'status is invalid', 'INVALID_STATUS');
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function normalizeOptionalEmail(value: string) {
|
||||
if (!value) return null;
|
||||
const email = value.toLowerCase();
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
|
||||
throw new HttpError(400, 'email is invalid', 'INVALID_EMAIL');
|
||||
}
|
||||
return email;
|
||||
}
|
||||
|
||||
function normalizeOptionalPhone(value: string) {
|
||||
if (!value) return null;
|
||||
const phone = value.replace(/\s+/g, '');
|
||||
if (!/^\+?[0-9-]{6,32}$/.test(phone)) {
|
||||
throw new HttpError(400, 'phone is invalid', 'INVALID_PHONE');
|
||||
}
|
||||
return phone;
|
||||
}
|
||||
|
||||
function normalizePlatformUsername(value: string, fallback: string) {
|
||||
const username = (value || fallback).trim();
|
||||
if (!/^[a-zA-Z0-9_.@-]{3,80}$/.test(username)) {
|
||||
throw new HttpError(400, 'username is invalid', 'INVALID_USERNAME');
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
function allowedPlatformPermissionKeys() {
|
||||
return new Set<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) {
|
||||
if (permissions['*'] === true) return true;
|
||||
if (permissions[permission] === true) return true;
|
||||
@@ -528,6 +609,239 @@ export async function platformPermissionsRoute(ctx: RequestContext) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function platformStaffRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx, 'platform:staff:read');
|
||||
|
||||
const status = listQuery(ctx, 'status');
|
||||
if (status && !PLATFORM_STAFF_STATUSES.has(status)) throw new HttpError(400, 'status is invalid', 'INVALID_STATUS');
|
||||
const q = listQuery(ctx, 'q');
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
|
||||
const items = await query<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) {
|
||||
await requirePlatformAdmin(ctx, 'platform:overview:read');
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export default defineAppConfig({
|
||||
'pages/platform-admin/tenants/index',
|
||||
'pages/platform-admin/billing/index',
|
||||
'pages/platform-admin/question-banks/index',
|
||||
'pages/platform-admin/staff/index',
|
||||
],
|
||||
window: {
|
||||
backgroundTextStyle: 'light',
|
||||
|
||||
@@ -219,6 +219,74 @@
|
||||
color: #be123c;
|
||||
}
|
||||
|
||||
.platform-mini-button.active {
|
||||
border-color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.platform-permission-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin: 8px 0 16px;
|
||||
padding: 18px;
|
||||
border: 1px solid #dbe4f0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.platform-permission-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.platform-permission-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.platform-chip-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.platform-chip-list.compact {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.platform-chip {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: #334155;
|
||||
font-size: 20px;
|
||||
font-weight: 680;
|
||||
line-height: 48px;
|
||||
}
|
||||
|
||||
.platform-chip.active {
|
||||
border-color: #1d4ed8;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.platform-chip.readonly {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
color: #475569;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.platform-error {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
|
||||
3
apps/taro/src/pages/platform-admin/staff/index.config.ts
Normal file
3
apps/taro/src/pages/platform-admin/staff/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '平台员工',
|
||||
});
|
||||
359
apps/taro/src/pages/platform-admin/staff/index.tsx
Normal file
359
apps/taro/src/pages/platform-admin/staff/index.tsx
Normal 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}?后端会默认撤销迁移期 session,Supabase JWT 也会因 status=disabled 被拒绝。`
|
||||
: `确认恢复 ${item.name || item.username || item.id} 的平台后台访问?`,
|
||||
);
|
||||
if (!ok) return;
|
||||
setBusy(`status-${item.id}-${nextStatus}`);
|
||||
try {
|
||||
await updatePlatformStaffStatus({
|
||||
staffId: item.id,
|
||||
status: nextStatus,
|
||||
reason: nextStatus === 'disabled' ? 'platform admin disabled from staff page' : 'platform admin restored from staff page',
|
||||
revokeSessions: nextStatus === 'disabled',
|
||||
});
|
||||
Taro.showToast({ title: nextStatus === 'disabled' ? '已禁用' : '已恢复', icon: 'success' });
|
||||
reload(status, keyword);
|
||||
} catch (nextError) {
|
||||
setError(nextError instanceof Error ? nextError.message : '员工状态更新失败');
|
||||
} finally {
|
||||
setBusy('');
|
||||
}
|
||||
}
|
||||
|
||||
const activeCount = staff.filter(item => item.status === 'active').length;
|
||||
const disabledCount = staff.filter(item => item.status === 'disabled').length;
|
||||
const selectedPermissions = permissionKeys(form.platformPermissions);
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -91,6 +91,7 @@ export default function PlatformWorkbenchPage() {
|
||||
{ name: '租户管理', path: '/pages/platform-admin/tenants/index', meta: '租户状态、套餐、欠费和到期' },
|
||||
{ name: '账务中心', path: '/pages/platform-admin/billing/index', meta: 'SaaS 套餐、发票、收款、用量' },
|
||||
{ name: '公共题库', path: '/pages/platform-admin/question-banks/index', meta: '地区题库、授权、披露范围' },
|
||||
{ name: '平台员工', path: '/pages/platform-admin/staff/index', meta: '员工账号、平台权限、禁用恢复' },
|
||||
];
|
||||
|
||||
async function exportAuditLogs() {
|
||||
@@ -161,6 +162,7 @@ export default function PlatformWorkbenchPage() {
|
||||
<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/question-banks/index' })}>公共题库</Button>
|
||||
<Button className='platform-button' onClick={() => Taro.navigateTo({ url: '/pages/platform-admin/staff/index' })}>平台员工</Button>
|
||||
</View>
|
||||
|
||||
<View className='platform-section'>
|
||||
|
||||
@@ -38,6 +38,23 @@ export interface PlatformPermissionSummary {
|
||||
catalog?: PlatformPermissionCatalogItem[];
|
||||
}
|
||||
|
||||
export interface PlatformStaffItem {
|
||||
id: string;
|
||||
authUserId?: string | null;
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
name?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
primaryRole?: string | null;
|
||||
status?: string | null;
|
||||
platformPermissions?: Record<string, unknown> | null;
|
||||
rawProfile?: Record<string, unknown> | null;
|
||||
lastSeenAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface PlatformSaasPlan {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -478,6 +495,26 @@ export interface UpsertPlatformQuestionBankGrantInput {
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface UpsertPlatformStaffInput {
|
||||
id?: string;
|
||||
authUserId?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
status?: string;
|
||||
platformPermissions?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface UpdatePlatformStaffStatusInput {
|
||||
staffId: string;
|
||||
status: 'active' | 'disabled';
|
||||
reason?: string;
|
||||
revokeSessions?: boolean;
|
||||
}
|
||||
|
||||
export async function loadPlatformOverview() {
|
||||
return apiRequest<{ item?: PlatformOverview }>('/api/platform-admin/overview', { tenantId: null });
|
||||
}
|
||||
@@ -486,6 +523,29 @@ export async function loadPlatformPermissions() {
|
||||
return apiRequest<{ item?: PlatformPermissionSummary }>('/api/platform-admin/permissions', { tenantId: null });
|
||||
}
|
||||
|
||||
export async function loadPlatformStaff(query: { q?: string; status?: string; limit?: number } = {}) {
|
||||
return apiRequest<{ items?: PlatformStaffItem[] }>('/api/platform-admin/staff', {
|
||||
query: { ...query, limit: query.limit || 80 },
|
||||
tenantId: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertPlatformStaff(input: UpsertPlatformStaffInput) {
|
||||
return apiRequest<{ item?: PlatformStaffItem }>('/api/platform-admin/staff', {
|
||||
method: 'PUT',
|
||||
body: input,
|
||||
tenantId: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updatePlatformStaffStatus(input: UpdatePlatformStaffStatusInput) {
|
||||
return apiRequest<{ item?: PlatformStaffItem }>('/api/platform-admin/staff/status', {
|
||||
method: 'PATCH',
|
||||
body: input,
|
||||
tenantId: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadPlatformPlans(includeArchived = false) {
|
||||
return apiRequest<{ items?: PlatformSaasPlan[] }>('/api/platform-admin/plans', {
|
||||
query: { includeArchived },
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
| 微信小程序登录 | 可联调 | `/api/auth/oauth/wechat-miniapp` 已接 `code2Session`、openid/unionid 身份、session 签发和登录审计 |
|
||||
| 手机号绑定/换绑 | 可联调 | `/api/auth/phone/bind` 使用 `bind_phone` 短信验证码,后端校验当前登录态、手机号唯一性、移除旧手机号 identity,并撤销其它迁移期 session |
|
||||
| 微信网页/QQ OAuth | 可联调 | `/api/auth/oauth/wechat` 已完成微信网页登录 code 换 token、userinfo、unionid 合并、session 签发和审计;`/api/auth/oauth/qq` 已完成 code/token/openid/userinfo 主链路;生产前需真实开放平台账号和回调域名联调 |
|
||||
| 平台管理员鉴权 | 可联调 | 已支持平台管理员 Supabase JWT;平台账号以后端 `platform_users.primary_role='platform_admin'` 和 `platform_permissions` 为准;`GET /api/platform-admin/permissions` 返回权限目录和当前账号 `effective` 能力,平台路由按 `platform:tenant:*`、`platform:billing:*`、`platform:audit:*`、`platform:question_bank:*` 等权限点强制校验;`x-platform-admin-key` 仅作本地/迁移期兼容且可通过配置禁用 |
|
||||
| 平台管理员鉴权 | 可联调 | 已支持平台管理员 Supabase JWT;平台账号以后端 `platform_users.primary_role='platform_admin'`、`status='active'` 和 `platform_permissions` 为准;`GET /api/platform-admin/permissions` 返回权限目录和当前账号 `effective` 能力,平台路由按 `platform:staff:*`、`platform:tenant:*`、`platform:billing:*`、`platform:audit:*`、`platform:question_bank:*` 等权限点强制校验;`GET/PUT/PATCH /api/platform-admin/staff` 可管理平台员工,禁用员工会拒绝后续 JWT 映射并撤销迁移期 session;`x-platform-admin-key` 仅作本地/迁移期兼容且可通过配置禁用 |
|
||||
| 租户角色权限 | 可联调 | `tenant_memberships.role + permissions + role_template_id`,接口有权限点校验 |
|
||||
| 自定义角色模板 | 可联调 | `tenant_role_templates` + `/api/tenant-admin/role-templates`,支持权限、菜单、模块、字段、数据范围配置;Taro 租户设置页已接创建、编辑、停用、权限点、菜单、模块、字段和基础数据范围配置第一版 |
|
||||
| 班级/教师/学生范围权限 | 可联调 | `tenant_classes`、`tenant_class_members` + `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`;教师默认只看自己负责班级,字段权限可脱敏学生手机号 |
|
||||
@@ -145,7 +145,7 @@
|
||||
| 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`,支持班级范围权限和审计 |
|
||||
| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 |
|
||||
| 用户站内通知查看 | 可联调 | `GET /api/tenant-admin/user-notifications`;需要 `notifications:read` 权限,支持按用户、状态、类型查询租户内通知和状态汇总,租户后台只读不直接代学生改状态 |
|
||||
| 平台租户/详情/账务资料/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持当前平台账号权限目录、租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志查询、CSV/JSON 审计导出、平台审计告警规则查询、告警列表、确认/解决/忽略、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款、逾期标记、内部催缴台账、催缴外部通知渠道和发送事件、用量;平台 API 已拆分 `platform:tenant:read/write/status/billing_profile`、`platform:billing:read/write/payment/dunning/notification`、`platform:audit:read/export/alert/notification`、`platform:question_bank:read/grant` 等权限点;审计导出、告警响应和通知事件都会对 `details`/payload 中的 token/secret/password/key 等敏感字段递归脱敏;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`apps/worker --job platform-audit-notifications` 会按 `platform_audit_notification_channels` 把开放告警推送到 generic/钉钉/飞书/企微 webhook,签名密钥放 `app_private.platform_secrets` 且 API 不回显原文;`apps/worker --job platform-dunning-notifications` 会按 `platform_dunning_notification_channels` 把内部催缴记录推送到 generic/钉钉/飞书/企微 webhook,发送成功会推进提醒状态,失败会退避重试,联系方式和请求 payload 会脱敏;创建租户、状态变更、账务资料维护、订阅批量开票、自动开票、逾期催缴、手工收款确认、审计导出、告警状态更新、通知渠道变更和催缴通知渠道变更会写入审计 |
|
||||
| 平台租户/详情/账务资料/员工/审计/告警/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*`;已支持当前平台账号权限目录、平台员工列表、平台员工创建/编辑、平台员工禁用/恢复、租户列表、创建租户、租户详情、状态变更、账务资料维护、平台审计日志查询、CSV/JSON 审计导出、平台审计告警规则查询、告警列表、确认/解决/忽略、审计告警外部通知渠道和发送事件、SaaS 套餐、订阅、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款、逾期标记、内部催缴台账、催缴外部通知渠道和发送事件、用量;平台 API 已拆分 `platform:staff:read/write/status`、`platform:tenant:read/write/status/billing_profile`、`platform:billing:read/write/payment/dunning/notification`、`platform:audit:read/export/alert/notification`、`platform:question_bank:read/grant` 等权限点;审计导出、平台员工操作、告警响应和通知事件都会对 `details`/payload 中的 token/secret/password/key 等敏感字段递归脱敏;`apps/worker --job platform-audit-alerts` 会把租户状态变更、账务资料变更、批量开票、逾期处理、手工收款确认、审计导出等高风险平台审计动作生成内部告警;`apps/worker --job platform-audit-notifications` 会按 `platform_audit_notification_channels` 把开放告警推送到 generic/钉钉/飞书/企微 webhook,签名密钥放 `app_private.platform_secrets` 且 API 不回显原文;`apps/worker --job platform-dunning-notifications` 会按 `platform_dunning_notification_channels` 把内部催缴记录推送到 generic/钉钉/飞书/企微 webhook,发送成功会推进提醒状态,失败会退避重试,联系方式和请求 payload 会脱敏;创建租户、平台员工变更、状态变更、账务资料维护、订阅批量开票、自动开票、逾期催缴、手工收款确认、审计导出、告警状态更新、通知渠道变更和催缴通知渠道变更会写入审计 |
|
||||
| 数据看板聚合接口 | 可联调 | `GET /api/tenant-admin/dashboard`;支持 `7d/30d/90d`、地区筛选、学生/学习/内容/订单/激活码/反馈卡片、趋势、24h 活跃、题型分布、科目排行、地区统计、套餐销量和运营动态 |
|
||||
| 平台公共题库授权 | 可联调 | `/api/platform-admin/question-banks`、`question-bank-grants`;支持按 SaaS 套餐、指定租户或全部活跃租户披露平台公共题库 |
|
||||
| 租户采纳/同步公共题库 | 可联调 | `/api/tenant-content/public-question-banks`、`public-question-banks/adopt`、`public-question-banks/sync`、`public-question-banks/conflicts`、`public-question-banks/conflicts/resolve`、`public-question-banks/conflicts/resolve-batch`、`tenant-content/notifications`;租户只能看到自己订阅/授权范围内题库,采纳后生成租户自己的题库、入口、集合和题目快照,可直接进入练习;平台更新后可手动或由 worker 自动同步,新增/更新和冲突会生成租户内容通知;租户自改题目会标记冲突并跳过;后台可查询最近一次冲突明细,并可单条或批量选择“采纳平台版本”/“保留本地版本”,操作会重新校验授权并写入逐条审计,冲突全部处理后相关通知自动 resolved |
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
| 模块 | 当前状态 | 已经具备 | 上线前还要补 |
|
||||
| --- | --- | --- | --- |
|
||||
| 多租户底座 | 可联调 | 租户、域名、品牌、设置、RLS 基础、审计、Supabase JWT/API 身份映射;`npm run test:rls` 已提供本地动态租户隔离验收;`npm run smoke:auth:remote` 已提供真实云端 Supabase access token 回归脚本 | 真实云端 Auth/JWKS 回归需要在预生产/生产环境执行并留档,生产 RLS 深测继续执行 |
|
||||
| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台账号细粒度权限目录、平台路由权限强校验、平台审计日志查询、平台审计 CSV/JSON 导出、平台审计告警规则/列表/确认/解决、platform-audit-alerts worker、审计告警外部通知渠道/事件 API、platform-audit-notifications worker、套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费、人工收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件 API、platform-dunning-notifications worker、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 平台员工账号创建/授权 UI 与管理 API、平台在线收款、平台审计告警升级策略和更完整运营消息 |
|
||||
| 平台后台 | 基础完成 | 租户、租户详情、账务资料维护、平台账号细粒度权限目录、平台员工列表/创建/编辑/启停、平台路由权限强校验、平台审计日志查询、平台审计 CSV/JSON 导出、平台审计告警规则/列表/确认/解决、platform-audit-alerts worker、审计告警外部通知渠道/事件 API、platform-audit-notifications worker、套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费、人工收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件 API、platform-dunning-notifications worker、用量、公共题库授权、公共题库自动同步 worker、公共题库冲突单条/批量处理 API、公共题库同步通知第一版 | 平台在线收款、平台审计告警升级策略和更完整运营消息 |
|
||||
| 租户后台 | 可联调 | 品牌、域名、支付账户、登录配置、密钥掩码、活动、兑换码、优惠券、勋章管理/手动发放/签到/积分/反馈/活动自动发放、积分任务、积分兑换、用户站内通知查看、成员权限、角色模板、菜单/模块/字段权限配置 API、班级/教师/学生范围权限;Taro 工作台已接权限驱动模块入口,学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户设置页已接角色模板和成员绑定操作台第一版,营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版 | 更细的数据范围组合、成员批量运营、真实打款/导出/凭证和完整权限菜单 |
|
||||
| 题库与练习 | 可联调 | 内容入口、任意深度分类、题目集合、顺序/随机/全真模拟蓝图、组卷快照、客观题后端判分、主观题 `selfJudgedCorrect` 自评、阅读理解/案例分析 `subAnswers` 多小题判分、答题、错题、收藏、模考报告、排行榜、公共题库采纳快照、手动同步、自动同步 worker、冲突查询/单条和批量处理 API、公共题库同步通知、JSON/试卷 payload 导出、PDF/Word 异步导出 worker、水印和资料发布路径、每日一练九宫格 metadata、PDF/Word 运营版式和 ZIP 图片素材包 | 长题干/公式图片混排体验、导出模板精排、导出操作台、排行榜防刷/预聚合 |
|
||||
| 背单词 | 可联调 | 单元、单词、进度、收藏、统计、每日计划、JSON/CSV/Excel 导入、排行榜 | 更细复习参数 |
|
||||
@@ -35,7 +35,7 @@
|
||||
| 内容导入 | 可联调 | 题目、单词、知识手册、分数线、视频 JSON/CSV/Excel preview/import、issue、job/detail、审计、幂等、`executionMode=async`、imports worker、导入后复检、模板下载、字段映射 API、字段映射覆盖白名单校验、PocketBase JSON dry-run 报告;Taro 租户内容页已接上传/粘贴预览、模板文件下载、字段别名编辑、同步/异步执行、异步轮询和复检详情第一版 | 真实数据 dry-run 执行验收、抽样校验和导入性能压测 |
|
||||
| 数据看板 | 可联调 | 租户 dashboard 聚合接口,收益、注册、学习、内容、激活码、反馈、趋势、24h 活跃、套餐销量和运营动态 | 预聚合 worker、缓存、慢 SQL 监控和销售转化看板 |
|
||||
| AI 择校推荐 | 可联调 | `ai_recommendation_reports`、SVIP 门禁、学生输入 schema、地区/分数线上下文、`local_rules` 稳定 JSON、报告列表/详情和 Taro 学生端基础页 | 真实 AI provider、prompt 版本管理、租户后台配置、报告 PDF 渲染和人工复核流程 |
|
||||
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作、租户详情、账务资料编辑、平台审计查询和 CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅账单候选预览/dry-run/批量生成、自动计费生成结果查看、逾期预览、催缴记录和催缴外部通知摘要第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 长题干/公式图片混排体验、更细数据范围 UI、平台审计告警升级策略、平台催缴通知配置操作台细节、小程序兼容验证和端到端测试 |
|
||||
| Taro 前端 | 地基已建 | `apps/taro` 已有 Taro 4 React 工程、H5 三入口、租户解析、统一 API client、Supabase Auth client 初始化;学生端、租户后台和平台后台均已有第一批真实 API 页面;学生端已接地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题作答、错题/收藏复习、题目反馈、视频解析、练习/模考报告、收银台、订单详情和售后入口第一版;平台后台已接关键写操作、租户详情、账务资料编辑、平台员工列表/创建/编辑/禁用恢复、平台审计查询和 CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅账单候选预览/dry-run/批量生成、自动计费生成结果查看、逾期预览、催缴记录和催缴外部通知摘要第一版,租户工作台已接权限驱动模块入口,租户学生运营页已接创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版;租户设置页已接角色模板和成员绑定操作台第一版;租户营销中心已接 CRM 配置保存、队列筛选、分佣规则、成员比例、订单明细、结算生成/审核/标记线下打款第一版 | 长题干/公式图片混排体验、更细数据范围 UI、平台审计告警升级策略、平台催缴通知配置操作台细节、小程序兼容验证和端到端测试 |
|
||||
|
||||
## 前端接入建议
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
- 对象存储:上传/下载签名已接入阿里云 OSS、腾讯云 COS、Supabase Storage;上传确认、PDF/图片预览签名、动态水印上下文、assets worker 复检、内置安全扫描、外部 HTTP scanner 接入层和题库导出 PDF/Word/每日一练 ZIP worker 已完成,继续补视频播放防盗链、真实 AV/内容安全服务联调和转码/CDN 级水印。
|
||||
- 真实数据 dry-run:导出 PocketBase 用户、题库、单词、知识手册、分数线、订单、权益,先跑 `npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings`,确认 `migrationReadiness` 的必需集合和关键字段覆盖率通过,再跑迁移和校验报告。
|
||||
- 生产环境配置:`.env.example` 和 `npm run readiness:production` / `npm run readiness:production:db` 已补;继续补数据库迁移流程、备份恢复、日志、告警和 API 容器部署说明。
|
||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口、积分任务/兑换/积分明细和消息中心第一版;平台后台关键写操作、租户详情、账务资料编辑、最近平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、逾期预览、催缴记录和催缴外部通知摘要第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版;下一步补独立消息中心增强、公式图片混排、更细数据范围 UI、平台审计告警升级策略、平台催缴通知配置操作台细节和小程序兼容验证。
|
||||
- Taro scaffold:`apps/taro` 地基已建立;学生端、租户后台、平台后台第一批 H5 页面已接真实 API,学生端已接地区选择、错题/收藏复习、阅读理解/案例分析多小题作答、题目反馈、视频解析、练习/模考报告、收银台、订单详情、售后入口、积分任务/兑换/积分明细和消息中心第一版;平台后台关键写操作、租户详情、账务资料编辑、平台员工列表/创建/编辑/禁用恢复、最近平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、逾期预览、催缴记录和催缴外部通知摘要第一版已接入,租户工作台已接权限驱动模块入口,租户学生运营页已接学生创建/更新、禁用/恢复、批量导入、批量分班、备注和跟进任务第一版,租户内容页已接公共题库采纳/同步、冲突查看、单条/批量采纳平台或保留本地、导入问题、字段模板预览/下载、上传/粘贴预览、字段别名覆盖、同步/异步导入、异步轮询和复检详情第一版,租户设置页已接角色模板创建/编辑/停用、成员绑定模板和权限可见性配置第一版,租户营销中心已接 CRM 配置/队列、分佣结算、优惠券规则/核销报表、积分任务/兑换操作台和用户通知查看第一版;下一步补独立消息中心增强、公式图片混排、更细数据范围 UI、平台审计告警升级策略、平台催缴通知配置操作台细节和小程序兼容验证。
|
||||
|
||||
### P1:商用收费和运营能力
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
| 蓝图模块 | 当前状态 | 已落地内容 | 待补内容 |
|
||||
| --- | --- | --- | --- |
|
||||
| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台账号细粒度权限点、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费收款、逾期标记、内部催缴台账、催缴外部通知、用量记录、公共题库披露策略第一版 | 地区/全国套餐权限细化、平台侧主题模板库、平台在线收款、平台审计报表增强和平台员工创建/授权 UI |
|
||||
| 平台超级管理员 | 部分完成 | 租户管理、租户详情、账务资料维护、平台员工创建/授权/启停、平台账号细粒度权限点、平台审计日志、SaaS 套餐、订阅、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、服务费收款、逾期标记、内部催缴台账、催缴外部通知、用量记录、公共题库披露策略第一版 | 地区/全国套餐权限细化、平台侧主题模板库、平台在线收款和平台审计报表增强 |
|
||||
| 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 |
|
||||
| 租户成员权限 | 可联调 | owner/admin/operator/teacher/sales/agent/student,权限矩阵,成员启停,角色模板、菜单/模块/字段权限、班级/学生范围权限和审计查询 | 前端权限 UI、更细的数据范围组合 |
|
||||
| 题库内容维护 | 可联调 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 预览导入、`executionMode=async` 导入 worker、导入后复检、模板/字段映射 API、视频绑定、分数线、单词、知识手册后台 API、公共题库授权、采纳快照、手动同步、自动同步 worker、同步通知和冲突查询 API | 字段映射 UI、公共题库失败告警/冲突操作台增强、可视化拖拽排序前端 |
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
- `apps/taro` 已经建立,且学生端第一批 H5 页面已经可构建:登录、首页、地区选择、题库、练习、错题/收藏、练习报告、视频解析、会员收银台、订单详情、背单词、知识手册、分数线、资料、个人中心。
|
||||
- 租户后台第一批 H5 页面已经可构建:工作台、数据看板、学生/班级、题库内容、营销中心、财务运营、租户设置;工作台已接 `/api/tenant-admin/permissions` 做权限驱动模块入口;学生运营页已具备学生创建/更新、状态禁用/恢复、批量导入、批量分班、学生备注和跟进任务第一版;题库内容页已具备公共题库采纳/同步、同步通知、冲突查看、单条/批量采纳平台版本或保留本地版本、导入任务详情、异步轮询、导入问题查看、模板预览/下载、导入后复检详情、JSON/CSV/Excel 选择文件或粘贴内容、后端预览、字段别名覆盖和同步/异步执行导入的第一版操作能力;营销中心已具备 CRM 配置、CRM 队列查看、分佣规则、成员分佣比例、分佣订单、结算单生成/审核/标记打款、优惠券规则/核销报表和用户通知查看第一版;财务运营页已具备退款申请/审核/供应商提交与查询、官方账单下载任务、对账批次/异常明细、差错工单处理、人工调整凭证提交/复核和异常订单运营台第一版;租户设置页已具备主题模板、草稿预览/发布、角色模板新建、编辑、停用、成员搜索/新建、成员绑定模板、成员状态和额外权限覆盖第一版。
|
||||
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权;启动时应先接 `GET /api/platform-admin/permissions` 获取 `effective` 权限用于菜单和按钮可见性;租户管理页已接租户详情、账务资料编辑和最近平台审计,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV,并可查看开放审计告警、确认或解决告警,也能查看审计告警外部通知渠道、催缴外部通知渠道和最近发送事件摘要;账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、逾期预览、内部催缴生成和催缴记录查看。
|
||||
- 平台后台第一批 H5 页面已经可构建:工作台、租户管理、账务中心、公共题库授权、平台员工;启动时应先接 `GET /api/platform-admin/permissions` 获取 `effective` 权限用于菜单和按钮可见性;租户管理页已接租户详情、账务资料编辑和最近平台审计,平台员工页已接员工列表、搜索、创建/编辑、权限点勾选、禁用和恢复,工作台已展示最近平台审计摘要、支持导出最近平台审计 CSV,并可查看开放审计告警、确认或解决告警,也能查看审计告警外部通知渠道、催缴外部通知渠道和最近发送事件摘要;账务中心已接订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、逾期预览、内部催缴生成和催缴记录查看。
|
||||
- 可以继续复刻旧题库学生端主要视觉和交互:勋章展示、小程序端分享/支付体验、背单词更细统计和更完整复盘体验。地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、题干/选项/解析 RichContent 安全渲染、视频解析、题目反馈、模考/练习报告逐题复盘、错题复习、收藏复习、背单词卡片学习/发音/收藏练习、商城收银台、订单详情和售后入口已经有第一版页面。
|
||||
- 可以按新后端主模型接入内容导航:
|
||||
- `content_entries`
|
||||
@@ -110,5 +110,6 @@
|
||||
| 租户管理 | `apps/taro/src/pages/platform-admin/tenants/index.tsx` | `platform-admin/tenants`、`POST tenants`、`tenants/detail`、`PATCH tenants/status`、`PUT tenants/billing-profile`、`audit-logs` |
|
||||
| 账务中心 | `apps/taro/src/pages/platform-admin/billing/index.tsx` | `platform-admin/plans`、`invoices`、`invoices/subscription-candidates`、`invoices/from-subscription`、`invoices/from-subscriptions-batch`、`invoices/payments/manual-confirm`、`usage`、`subscriptions`、`POST usage` |
|
||||
| 公共题库 | `apps/taro/src/pages/platform-admin/question-banks/index.tsx` | `platform-admin/question-banks`、`question-bank-grants`、`PUT question-bank-grants` |
|
||||
| 平台员工 | `apps/taro/src/pages/platform-admin/staff/index.tsx` | `platform-admin/permissions`、`platform-admin/staff`、`PUT platform-admin/staff`、`PATCH platform-admin/staff/status` |
|
||||
|
||||
当前平台后台已经具备第一批写操作台:创建租户、租户详情查看、状态变更、账务资料维护、最近平台审计查询、最近平台审计 CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件摘要、催缴外部通知渠道/事件摘要、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、人工收款确认、逾期预览、内部催缴生成、催缴记录查看、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限、重复开票保护和审计。平台后台的菜单和按钮必须用 `platform-admin/permissions` 返回的 `effective` 做可见性控制,但安全边界仍以后端 `PLATFORM_PERMISSION_REQUIRED` 为准。平台审计导出只开放给具备 `platform:audit:export` 的平台账号,后端会对导出 `details` 中的 token/secret/password/key 等敏感字段脱敏,并返回 `contentBase64 + sha256`,H5 可直接下载,小程序端建议先展示“已生成,需在 H5 管理台下载”。平台审计告警由 `platform-audit-alerts` worker 从高风险平台审计动作生成,外部通知由 `platform-audit-notifications` worker 根据平台渠道配置发送;平台催缴外部通知由 `platform-dunning-notifications` worker 根据 `tenant_invoice_reminders` 和平台渠道配置发送。前端只能调用告警查询、状态更新、通知渠道和发送事件 API,不要直接写 `platform_audit_alerts`、`platform_audit_notification_channels`、`platform_audit_notification_events`、`platform_dunning_notification_channels` 或 `platform_dunning_notification_events` 表。后端会对告警 `details`、通知 payload 和催缴 payload 递归脱敏,渠道 API 只回显 `secretRef` 和 webhook host/path。下一批继续补租户基础资料编辑增强、平台员工创建/授权 UI 与管理 API、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。
|
||||
当前平台后台已经具备第一批写操作台:创建租户、租户详情查看、状态变更、账务资料维护、平台员工创建/编辑/禁用恢复、平台员工权限点勾选、最近平台审计查询、最近平台审计 CSV 导出、开放审计告警确认/解决、审计告警外部通知渠道/事件摘要、催缴外部通知渠道/事件摘要、订阅开通、账单生成、订阅账单候选预览、dry-run、批量生成、自动计费生成结果查看、人工收款确认、逾期预览、内部催缴生成、催缴记录查看、用量录入、公共题库授权编辑;这些动作均经过前端基础校验和二次确认,后端继续执行真实权限、重复开票保护和审计。平台后台的菜单和按钮必须用 `platform-admin/permissions` 返回的 `effective` 做可见性控制,但安全边界仍以后端 `PLATFORM_PERMISSION_REQUIRED` 为准。平台员工创建必须绑定 Supabase Auth 用户 ID,前端只提交公开资料和权限点,不创建密码账号、不接触 service role;禁用员工时应调用 `PATCH /api/platform-admin/staff/status` 并默认 `revokeSessions=true`。平台审计导出只开放给具备 `platform:audit:export` 的平台账号,后端会对导出 `details` 中的 token/secret/password/key 等敏感字段脱敏,并返回 `contentBase64 + sha256`,H5 可直接下载,小程序端建议先展示“已生成,需在 H5 管理台下载”。平台审计告警由 `platform-audit-alerts` worker 从高风险平台审计动作生成,外部通知由 `platform-audit-notifications` worker 根据平台渠道配置发送;平台催缴外部通知由 `platform-dunning-notifications` worker 根据 `tenant_invoice_reminders` 和平台渠道配置发送。前端只能调用告警查询、状态更新、通知渠道和发送事件 API,不要直接写 `platform_audit_alerts`、`platform_audit_notification_channels`、`platform_audit_notification_events`、`platform_dunning_notification_channels` 或 `platform_dunning_notification_events` 表。后端会对告警 `details`、通知 payload 和催缴 payload 递归脱敏,渠道 API 只回显 `secretRef` 和 webhook host/path。下一批继续补租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
## 当前结论
|
||||
|
||||
当前重构已经完成了 Supabase/PostgreSQL 多租户底座、核心业务表、PocketBase 数据导入器雏形、学生端核心 API、租户后台 API、平台后台 SaaS 账务 API、平台账号细粒度权限地基、内容资产/题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入基础闭环、异步导入 worker、题库导出基础、PDF/Word/每日一练 ZIP 导出 worker、题库入口/任意深度分类/题目集合/练习蓝图/组卷快照基础闭环,以及本地 Docker/API 构建验证。
|
||||
当前重构已经完成了 Supabase/PostgreSQL 多租户底座、核心业务表、PocketBase 数据导入器雏形、学生端核心 API、租户后台 API、平台后台 SaaS 账务 API、平台账号细粒度权限和平台员工管理地基、内容资产/题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 批量导入基础闭环、异步导入 worker、题库导出基础、PDF/Word/每日一练 ZIP 导出 worker、题库入口/任意深度分类/题目集合/练习蓝图/组卷快照基础闭环,以及本地 Docker/API 构建验证。
|
||||
|
||||
但这还不是完整商用交付状态,也不能说旧项目核心功能已经全部重构完成。现在更准确的状态是:后端商用架构骨架已经立住,核心业务正在按模块补齐。部分功能已经有可调用 API,部分功能只有数据模型和导入映射,部分功能还没有前端/自动化测试闭环。
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告、勋章、积分任务、积分兑换商品、兑换订单表和用户站内通知表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码预检查/兑换、激活码批次、批量生成激活码、优惠券维护、前台领取/下单抵扣、最低金额、优惠封顶、单用户限次、首单限制、适用套餐/地区、活动分组、核销明细、核销报表、勋章维护、手动发放、签到/积分/反馈/活动任务自动发放、积分任务领取、积分兑换、优惠券兑换履约和站内通知已实现 | 核心 API 集成测试 | Taro 租户营销中心已接优惠券、积分任务/兑换和用户通知查看第一版;连续签到奖励配置、练习/单词/模考触发勋章、营销自动化、积分风控报表、外部订阅消息/短信和更完整活动效果看板继续补 |
|
||||
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列、CRM worker 推送已实现 | 核心 API 集成测试、CRM worker 集成测试 | 增长链路基础可用,真实微信小程序码、CRM 分配策略、富卡片和销售转化看板待补 |
|
||||
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、角色模板权限/菜单/模块/字段/数据范围配置、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目/单词/知识手册/分数线/视频 JSON/CSV/Excel 同步/异步导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用;Taro 已接角色模板操作台、字段映射操作台和导入复检结果面板第一版;继续补成员绑定模板、权限驱动菜单和更细数据范围 UI |
|
||||
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量、审计日志、催缴台账、催缴通知事件和平台权限字段 | 不适用 | 租户管理、租户详情、账务资料维护、平台账号权限目录、平台路由细粒度权限强校验、平台审计日志、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款确认、逾期标记、内部催缴记录、催缴外部通知渠道/事件、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台细粒度权限、平台租户创建、详情、账务资料更新、状态变更、审计查询、订阅批量开票、重复保护、逾期 dry-run/处理/提醒查询、催缴通知渠道/事件脱敏、非法输入拒绝和学生越权拒绝;`npm run test:worker:platform-billing` 覆盖自动计费幂等和审计,`npm run test:worker:platform-dunning` 覆盖逾期催缴幂等和审计,`npm run test:worker:platform-dunning-notifications` 覆盖催缴外部通知幂等、联系方式掩码和密钥不泄露 | 平台收费和租户运营链路骨架可用,平台员工创建/授权 UI、平台在线收款和更完整平台审计报表待补 |
|
||||
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量、审计日志、催缴台账、催缴通知事件、平台权限字段和平台员工状态字段 | 不适用 | 租户管理、租户详情、账务资料维护、平台账号权限目录、平台员工列表/创建/编辑/启停、平台路由细粒度权限强校验、平台审计日志、账单、订阅账单候选预览、dry-run、批量生成、自动计费 worker、重复开票保护、收款确认、逾期标记、内部催缴记录、催缴外部通知渠道/事件、用量记录、平台管理员 Supabase JWT 鉴权已实现 | API 集成测试已覆盖平台细粒度权限、平台员工创建/权限目录/JWT 访问/越权拒绝/自降级拒绝/禁用后 JWT 拒绝/审计脱敏、平台租户创建、详情、账务资料更新、状态变更、审计查询、订阅批量开票、重复保护、逾期 dry-run/处理/提醒查询、催缴通知渠道/事件脱敏、非法输入拒绝和学生越权拒绝;`npm run test:worker:platform-billing` 覆盖自动计费幂等和审计,`npm run test:worker:platform-dunning` 覆盖逾期催缴幂等和审计,`npm run test:worker:platform-dunning-notifications` 覆盖催缴外部通知幂等、联系方式掩码和密钥不泄露;Taro 类型检查覆盖平台员工管理页面 | 平台收费、租户运营和员工授权链路骨架可用,平台在线收款和更完整平台审计报表待补 |
|
||||
| 登录认证 | 已建短信验证码、会话、OAuth provider 配置表,并支持 `auth_user_id` 映射 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、Supabase JWT 验签映射、微信小程序登录主链路、微信网页登录、QQ 登录、手机号绑定/换绑已实现 | API 集成测试 | H5 Supabase Auth 可联调;真实短信/OAuth 生产账号和回调域名联调待补 |
|
||||
| 数据导入 | 已建立 importer、risk report、dry-run report、validate | 已覆盖多类旧集合 | 命令行 dry-run/导入/校验 | `pb:import:dry-run`、`pb:import:validate`、`test:pb:dry-run` 覆盖 strict warning 和关系断裂门禁 | 基础工具和真实迁移 runbook 可用,需拿真实完整数据执行多轮 dry-run、导入回归和抽样验收 |
|
||||
| 测试体系 | 不适用 | 不适用 | 不适用 | 已新增核心 API 集成测试、租户隔离测试、权限矩阵测试、资源/题目导入测试、导入校验 | 还不是完整覆盖,支付幂等、真实导入回归、前端端到端测试仍需补 |
|
||||
|
||||
@@ -62,8 +62,9 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小
|
||||
3. 平台管理员鉴权
|
||||
- `x-platform-admin-key` 已可通过 `ALLOW_PLATFORM_ADMIN_KEY=false` 禁用。
|
||||
- 已支持平台管理员 Supabase JWT,且以后端 `platform_users.primary_role='platform_admin'` 为准,不只信 JWT claim。
|
||||
- 平台管理员已支持 `platform_users.platform_permissions` 细粒度权限,`{"*":true}` 为超级管理员;接口按 `platform:tenant:*`、`platform:billing:*`、`platform:audit:*`、`platform:question_bank:*` 等权限点强制校验。
|
||||
- 生产前继续补平台后台关键操作审计报表和平台员工创建/授权 UI。
|
||||
- 平台管理员已支持 `platform_users.platform_permissions` 细粒度权限,`{"*":true}` 为超级管理员;接口按 `platform:staff:*`、`platform:tenant:*`、`platform:billing:*`、`platform:audit:*`、`platform:question_bank:*` 等权限点强制校验。
|
||||
- 平台员工管理已落到 `GET/PUT/PATCH /api/platform-admin/staff` 和 Taro 平台员工页;员工必须绑定 Supabase Auth 用户 ID,`platform_users.status='disabled'` 后不能再通过 Supabase JWT 映射为平台管理员,禁用时也会默认撤销迁移期 session。
|
||||
- 生产前继续补平台后台关键操作审计报表。
|
||||
|
||||
4. 生产配置 fail-fast
|
||||
- `NODE_ENV=production` 时禁止默认 `AUTH_CODE_PEPPER`。
|
||||
@@ -157,6 +158,9 @@ provider event id 幂等
|
||||
| 权限点 | 用途 |
|
||||
| --- | --- |
|
||||
| `platform:overview:read` | 查看平台总览 |
|
||||
| `platform:staff:read` | 查看平台员工 |
|
||||
| `platform:staff:write` | 创建/编辑平台员工 |
|
||||
| `platform:staff:status` | 启停平台员工并撤销迁移期 session |
|
||||
| `platform:tenant:read` | 查看租户列表和租户详情 |
|
||||
| `platform:tenant:write` | 创建/编辑租户 |
|
||||
| `platform:tenant:status` | 变更租户状态 |
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、排行榜、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单详情/状态轮询、优惠券领取/抵扣、激活码预检查/兑换、权益、个人中心、考试倒计时、签到积分、题目反馈、勋章、站内通知。
|
||||
- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、考试日期、题目反馈处理、用户站内通知查看、激活码、优惠券、勋章管理/发放、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。
|
||||
- 租户主题系统:平台默认经典蓝、专注绿、高对比三套模板,租户可保存草稿、发布主题,公开租户解析只返回已发布主题,Taro 租户设置页已接第一版主题操作台。
|
||||
- 平台后台 API/worker:租户、租户详情、账务资料维护、平台审计日志查询/导出、平台审计告警规则/列表/确认/解决、审计告警外部通知渠道/事件、platform-audit-alerts worker、platform-audit-notifications worker、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件、platform-dunning-notifications worker、用量。
|
||||
- 平台后台 API/worker:租户、租户详情、账务资料维护、平台员工列表/创建/编辑/启停、平台审计日志查询/导出、平台审计告警规则/列表/确认/解决、审计告警外部通知渠道/事件、platform-audit-alerts worker、platform-audit-notifications worker、SaaS 套餐、订阅、订阅账单候选预览/dry-run/批量生成、自动计费 worker、服务费收款、逾期标记、内部催缴台账、催缴外部通知渠道/事件、platform-dunning-notifications worker、用量。
|
||||
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 配置/队列、`none/direct/round_robin/referrer` 跟进分配策略、CRM worker、分佣规则、成员比例、订单/激活码归因、结算生成、审核、打款状态、结算导出和凭证复核;Taro 租户营销中心已接 CRM、分佣和优惠券规则/核销报表第一版操作台。
|
||||
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
|
||||
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
|
||||
@@ -190,7 +190,7 @@
|
||||
- H5 和小程序共用同一套业务 API client。
|
||||
- 租户通过域名、小程序配置或启动参数解析。
|
||||
- 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。
|
||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、站内消息中心第一版、积分任务/兑换/积分明细第一版、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、催缴外部通知渠道/事件摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、独立消息中心增强、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
|
||||
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client;学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、站内消息中心第一版、积分任务/兑换/积分明细第一版、题干/选项/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台员工列表/创建/编辑/禁用恢复、平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、催缴外部通知渠道/事件摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、独立消息中心增强、状态管理、更细数据范围 UI、学生批量运营增强和小程序兼容验证。
|
||||
|
||||
### 第一批页面
|
||||
|
||||
@@ -243,7 +243,7 @@
|
||||
|
||||
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。
|
||||
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、收银台、订单详情、售后入口、站内消息筛选/已读/归档、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、独立消息中心增强、背单词更细统计、小程序支付容器、分享场景和状态管理。
|
||||
3. 补平台后台增强:租户基础资料编辑增强、平台员工创建/授权 UI 与管理 API、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。
|
||||
3. 补平台后台增强:租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。
|
||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||
5. 导出现有 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 做 production dry-run、导入演练、校验和抽样验收。
|
||||
6. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
||||
|
||||
@@ -22,6 +22,7 @@ const AUTH_USER_ID = '00000000-0000-0000-0000-00000000a101';
|
||||
const AUTH_TENANT_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a102';
|
||||
const AUTH_PLATFORM_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a999';
|
||||
const AUTH_RESTRICTED_PLATFORM_ADMIN_USER_ID = '00000000-0000-0000-0000-00000000a998';
|
||||
const AUTH_PLATFORM_STAFF_USER_ID = '00000000-0000-4000-8000-00000000a997';
|
||||
const AUTH_JWT_SECRET = 'development-jwt-secret-change-me';
|
||||
const START_SERVER = process.argv.includes('--start-server');
|
||||
const ENABLE_REAL_STORAGE_SIGN_TESTS = process.env.ENABLE_REAL_STORAGE_SIGN_TESTS === 'true';
|
||||
@@ -1179,6 +1180,247 @@ async function testPlatformAdminPermissions() {
|
||||
assert.equal(paymentDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'manual service-fee payment must require billing payment permission');
|
||||
}
|
||||
|
||||
async function testPlatformStaffManagement() {
|
||||
const adminHeaders = { 'x-platform-admin-key': 'local-platform-admin-key' };
|
||||
const staffSuffix = Date.now().toString(36);
|
||||
const staffEmail = `platform.staff.${staffSuffix}@example.test`;
|
||||
const staffPhone = `139${String(Date.now()).slice(-8)}`;
|
||||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||||
try {
|
||||
await pool.query(
|
||||
`
|
||||
insert into auth.users (
|
||||
id, aud, role, phone, email, phone_confirmed_at, email_confirmed_at,
|
||||
raw_app_meta_data, raw_user_meta_data, created_at, updated_at
|
||||
)
|
||||
values (
|
||||
$1, 'authenticated', 'authenticated', $2, $3,
|
||||
now(), now(),
|
||||
'{"provider":"phone","providers":["phone"],"app_role":"platform_admin"}'::jsonb,
|
||||
'{}'::jsonb, now(), now()
|
||||
)
|
||||
on conflict (id)
|
||||
do update set phone = excluded.phone,
|
||||
email = excluded.email,
|
||||
raw_app_meta_data = excluded.raw_app_meta_data,
|
||||
updated_at = now()
|
||||
`,
|
||||
[AUTH_PLATFORM_STAFF_USER_ID, staffPhone, staffEmail],
|
||||
);
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
const created = await request('/api/platform-admin/staff', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
authUserId: AUTH_PLATFORM_STAFF_USER_ID,
|
||||
username: 'platform_staff_operator',
|
||||
email: staffEmail,
|
||||
phone: staffPhone,
|
||||
name: 'Platform Staff Operator',
|
||||
status: 'active',
|
||||
platformPermissions: {
|
||||
'platform:staff:read': true,
|
||||
'platform:overview:read': true,
|
||||
'platform:tenant:read': true,
|
||||
},
|
||||
metadata: {
|
||||
title: 'operations',
|
||||
secretToken: 'should-not-be-returned-through-audit',
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.ok(created.item?.id, 'platform admin should create platform staff');
|
||||
assert.equal(created.item?.platformPermissions?.['platform:tenant:read'], true, 'platform staff response should include granted permissions');
|
||||
assert.ok(!JSON.stringify(created).includes('local-platform-admin-key'), 'platform staff response must not leak platform key');
|
||||
|
||||
const invalidPermission = await request('/api/platform-admin/staff', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
authUserId: AUTH_PLATFORM_STAFF_USER_ID,
|
||||
username: 'invalid_platform_staff',
|
||||
name: 'Invalid Platform Staff',
|
||||
platformPermissions: {
|
||||
'platform:unknown:write': true,
|
||||
},
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(invalidPermission.code, 'INVALID_PLATFORM_PERMISSION', 'platform staff permissions should reject unknown permission keys');
|
||||
|
||||
const missingAuthUser = await request('/api/platform-admin/staff', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
authUserId: '00000000-0000-4000-8000-00000000dead',
|
||||
username: 'missing_auth_platform_staff',
|
||||
name: 'Missing Auth Platform Staff',
|
||||
platformPermissions: {
|
||||
'platform:overview:read': true,
|
||||
},
|
||||
},
|
||||
expectStatus: 404,
|
||||
});
|
||||
assert.equal(missingAuthUser.code, 'AUTH_USER_NOT_FOUND', 'platform staff must bind an existing Supabase Auth user');
|
||||
|
||||
const missingAuthBinding = await request('/api/platform-admin/staff', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
username: 'unbound_platform_staff',
|
||||
name: 'Unbound Platform Staff',
|
||||
platformPermissions: {
|
||||
'platform:overview:read': true,
|
||||
},
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(missingAuthBinding.code, 'REQUIRED_FIELD', 'platform staff authUserId should be required');
|
||||
|
||||
const emptyPermissionDenied = await request('/api/platform-admin/staff', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
authUserId: AUTH_PLATFORM_STAFF_USER_ID,
|
||||
username: 'empty_permission_platform_staff',
|
||||
name: 'Empty Permission Platform Staff',
|
||||
status: 'active',
|
||||
platformPermissions: {},
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(emptyPermissionDenied.code, 'PLATFORM_PERMISSION_EMPTY', 'active platform staff should require explicit permissions');
|
||||
|
||||
const staffList = await request('/api/platform-admin/staff', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
query: { q: 'platform_staff_operator' },
|
||||
});
|
||||
assert.ok(staffList.items?.some(item => item.id === created.item.id), 'platform staff list should include created staff');
|
||||
|
||||
const staffJwt = await createSupabaseJwt(AUTH_PLATFORM_STAFF_USER_ID, {
|
||||
phone: staffPhone,
|
||||
appRole: 'platform_admin',
|
||||
tenantId: false,
|
||||
});
|
||||
const staffHeaders = { authorization: `Bearer ${staffJwt}` };
|
||||
|
||||
const staffPermissions = await request('/api/platform-admin/permissions', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: staffHeaders,
|
||||
});
|
||||
assert.equal(staffPermissions.item?.effective?.['platform:staff:read'], true, 'staff should expose its staff read permission');
|
||||
assert.equal(staffPermissions.item?.effective?.['platform:tenant:write'], false, 'staff should not expose ungranted tenant write permission');
|
||||
|
||||
const staffCanListTenants = await request('/api/platform-admin/tenants', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: staffHeaders,
|
||||
});
|
||||
assert.ok(Array.isArray(staffCanListTenants.items), 'staff should list tenants with tenant read permission');
|
||||
|
||||
const staffCreateTenantDenied = await request('/api/platform-admin/tenants', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: staffHeaders,
|
||||
method: 'POST',
|
||||
body: {
|
||||
slug: `staff-denied-${Date.now().toString(36)}`,
|
||||
name: 'Staff Denied Tenant',
|
||||
},
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(staffCreateTenantDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'platform staff should not create tenants without tenant write permission');
|
||||
|
||||
const staffWriteDenied = await request('/api/platform-admin/staff', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: staffHeaders,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
username: 'staff_cannot_create_staff',
|
||||
name: 'Staff Cannot Create Staff',
|
||||
platformPermissions: { 'platform:overview:read': true },
|
||||
},
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(staffWriteDenied.code, 'PLATFORM_PERMISSION_REQUIRED', 'staff write should require explicit platform staff write permission');
|
||||
|
||||
const platformAdminJwt = await createSupabaseJwt(AUTH_PLATFORM_ADMIN_USER_ID, {
|
||||
phone: '13999999999',
|
||||
appRole: 'platform_admin',
|
||||
tenantId: false,
|
||||
});
|
||||
const platformAdminHeaders = { authorization: `Bearer ${platformAdminJwt}` };
|
||||
|
||||
const selfDowngradeDenied = await request('/api/platform-admin/staff', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: platformAdminHeaders,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
id: '00000000-0000-0000-0000-000000000999',
|
||||
authUserId: AUTH_PLATFORM_ADMIN_USER_ID,
|
||||
username: 'smoke_platform_admin',
|
||||
phone: '13999999999',
|
||||
name: 'Smoke Platform Admin',
|
||||
status: 'active',
|
||||
platformPermissions: { 'platform:overview:read': true },
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(selfDowngradeDenied.code, 'CANNOT_DOWNGRADE_SELF', 'platform admin should not remove its own super permission');
|
||||
|
||||
const disabled = await request('/api/platform-admin/staff/status', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
staffId: created.item.id,
|
||||
status: 'disabled',
|
||||
reason: 'integration disable test',
|
||||
revokeSessions: true,
|
||||
},
|
||||
});
|
||||
assert.equal(disabled.item?.status, 'disabled', 'platform admin should disable platform staff');
|
||||
|
||||
const disabledStaffDenied = await request('/api/platform-admin/overview', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: staffHeaders,
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(disabledStaffDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'disabled platform staff JWT should no longer authenticate as platform admin');
|
||||
|
||||
const audit = await request('/api/platform-admin/audit-logs', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
query: { targetType: 'platform_user', q: 'platform.staff', limit: 20 },
|
||||
});
|
||||
assert.ok(
|
||||
audit.items?.some(item => item.targetId === created.item.id && item.action === 'platform.staff.status_updated'),
|
||||
'platform staff status changes should be audited',
|
||||
);
|
||||
assert.ok(!JSON.stringify(audit).includes('should-not-be-returned-through-audit'), 'platform staff audit should not leak token-like metadata');
|
||||
}
|
||||
|
||||
async function testSupabaseJwksIdentity() {
|
||||
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const publicJwk = await exportJWK(publicKey);
|
||||
@@ -9014,6 +9256,7 @@ async function main() {
|
||||
await check('phone binding', testPhoneBinding);
|
||||
await check('Supabase JWT identity', testSupabaseJwtIdentity);
|
||||
await check('platform admin permissions', testPlatformAdminPermissions);
|
||||
await check('platform staff management', testPlatformStaffManagement);
|
||||
await check('Supabase JWKS JWT identity', testSupabaseJwksIdentity);
|
||||
await check('legacy auth headers disabled', testLegacyAuthHeadersDisabled);
|
||||
await check('platform tenant operations and audit', testPlatformTenantOperationsAndAudit);
|
||||
|
||||
@@ -83,11 +83,44 @@ function runWorkerOnce() {
|
||||
}
|
||||
|
||||
async function cleanup(pool) {
|
||||
await pool.query(
|
||||
`
|
||||
delete from public.platform_audit_notification_events
|
||||
where channel_id in (
|
||||
select id
|
||||
from public.platform_audit_notification_channels
|
||||
where channel_code in ('worker_platform_audit_test', 'integration_platform_audit')
|
||||
)
|
||||
or alert_id in (
|
||||
select id
|
||||
from public.platform_audit_alerts
|
||||
where details->>'source' in ('worker-test', 'api-integration-test')
|
||||
or details->>'workerId' = 'platform-audit-alert-worker-test'
|
||||
)
|
||||
`,
|
||||
);
|
||||
await pool.query("delete from public.platform_audit_notification_events where alert_id = $1", [ids.alert]);
|
||||
await pool.query("delete from public.platform_audit_notification_channels where channel_code = 'worker_platform_audit_test'");
|
||||
await pool.query("delete from app_private.platform_secrets where secret_scope = 'webhook' and secret_key = 'worker_platform_audit_test'");
|
||||
await pool.query('delete from public.platform_audit_alerts where id = $1 or audit_log_id = $2', [ids.alert, ids.auditLog]);
|
||||
await pool.query('delete from public.audit_logs where id = $1 or tenant_id = $2', [ids.auditLog, ids.tenant]);
|
||||
await pool.query(
|
||||
`
|
||||
delete from public.platform_audit_alerts
|
||||
where id = $1
|
||||
or audit_log_id = $2
|
||||
or details->>'source' in ('worker-test', 'api-integration-test')
|
||||
or details->>'workerId' = 'platform-audit-alert-worker-test'
|
||||
`,
|
||||
[ids.alert, ids.auditLog],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
delete from public.audit_logs
|
||||
where id = $1
|
||||
or tenant_id = $2
|
||||
or user_agent in ('platform-audit-notification-worker-test', 'platform-audit-alert-worker-test')
|
||||
`,
|
||||
[ids.auditLog, ids.tenant],
|
||||
);
|
||||
await pool.query('delete from public.tenant_billing_profiles where tenant_id = $1', [ids.tenant]);
|
||||
await pool.query('delete from public.tenant_domains where tenant_id = $1', [ids.tenant]);
|
||||
await pool.query('delete from public.tenants where id = $1', [ids.tenant]);
|
||||
@@ -108,7 +141,7 @@ async function seed(pool, webhookUrl) {
|
||||
details, ip_address, user_agent, created_at
|
||||
)
|
||||
values (
|
||||
$1, $2::uuid, null, 'platform.tenant.status_updated', 'tenant', $3,
|
||||
$1, $2::uuid, null, 'platform.worker_test.status_updated', 'tenant', $3,
|
||||
'{"status":"suspended","apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb,
|
||||
'127.0.0.1', 'platform-audit-notification-worker-test', now()
|
||||
)
|
||||
@@ -125,7 +158,7 @@ async function seed(pool, webhookUrl) {
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4::uuid, 'high', 'open',
|
||||
'platform.tenant.status_updated', 'tenant', $4,
|
||||
'platform.worker_test.status_updated', 'tenant', $4,
|
||||
'租户状态变更告警', 'platform audit notification integration alert',
|
||||
'{"source":"worker-test","apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb,
|
||||
now(), now()
|
||||
@@ -153,7 +186,7 @@ async function seed(pool, webhookUrl) {
|
||||
values (
|
||||
'worker_platform_audit_test', 'Worker 平台审计通知', true, 'generic',
|
||||
$1, 'app_private.platform_secrets:webhook:worker_platform_audit_test',
|
||||
'medium', array['open']::text[], array['platform.tenant.*']::text[], 5
|
||||
'medium', array['open']::text[], array['platform.worker_test.*']::text[], 5
|
||||
)
|
||||
on conflict (channel_code)
|
||||
do update set enabled = excluded.enabled,
|
||||
|
||||
@@ -394,10 +394,47 @@ async function validateDatabase() {
|
||||
pass('db.provider_public_config', 'Active provider public configs do not contain secret-like keys');
|
||||
}
|
||||
|
||||
const activePlatformAdminRows = await pool.query(`
|
||||
select id, username, phone, auth_user_id
|
||||
from public.platform_users
|
||||
where primary_role = 'platform_admin'
|
||||
and status = 'active'
|
||||
order by created_at asc
|
||||
limit 20
|
||||
`);
|
||||
if (activePlatformAdminRows.rowCount === 0) {
|
||||
block('db.platform_admin_active', 'At least one active platform admin user is required');
|
||||
} else {
|
||||
pass('db.platform_admin_active', 'Active platform admin users found', { count: activePlatformAdminRows.rowCount });
|
||||
}
|
||||
|
||||
const activePlatformAdminsWithoutAuth = await pool.query(`
|
||||
select id, username, phone
|
||||
from public.platform_users
|
||||
where primary_role = 'platform_admin'
|
||||
and status = 'active'
|
||||
and auth_user_id is null
|
||||
order by created_at asc
|
||||
limit 20
|
||||
`);
|
||||
if (activePlatformAdminsWithoutAuth.rowCount > 0) {
|
||||
block('db.platform_admin_auth_binding', 'Active platform admin users must be bound to Supabase Auth users', {
|
||||
count: activePlatformAdminsWithoutAuth.rowCount,
|
||||
samples: activePlatformAdminsWithoutAuth.rows.map(row => ({
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
phone: row.phone ? `${String(row.phone).slice(0, 3)}****${String(row.phone).slice(-4)}` : null,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
pass('db.platform_admin_auth_binding', 'Active platform admin users are bound to Supabase Auth users');
|
||||
}
|
||||
|
||||
const platformAdminsWithoutPermissions = await pool.query(`
|
||||
select id, username, phone
|
||||
from public.platform_users
|
||||
where primary_role = 'platform_admin'
|
||||
and status = 'active'
|
||||
and (platform_permissions is null or platform_permissions = '{}'::jsonb)
|
||||
order by created_at asc
|
||||
limit 20
|
||||
@@ -415,6 +452,32 @@ async function validateDatabase() {
|
||||
pass('db.platform_admin_permissions', 'Platform admin users have explicit permission maps');
|
||||
}
|
||||
|
||||
const disabledPlatformAdminsWithActiveSessions = await pool.query(`
|
||||
select u.id, u.username, u.phone, count(s.id)::int as active_session_count
|
||||
from public.platform_users u
|
||||
join app_private.auth_sessions s on s.user_id = u.id
|
||||
where u.primary_role = 'platform_admin'
|
||||
and u.status = 'disabled'
|
||||
and s.revoked_at is null
|
||||
and s.expires_at > now()
|
||||
group by u.id, u.username, u.phone
|
||||
order by active_session_count desc
|
||||
limit 20
|
||||
`);
|
||||
if (disabledPlatformAdminsWithActiveSessions.rowCount > 0) {
|
||||
block('db.platform_admin_disabled_sessions', 'Disabled platform admin users must not have active legacy sessions', {
|
||||
count: disabledPlatformAdminsWithActiveSessions.rowCount,
|
||||
samples: disabledPlatformAdminsWithActiveSessions.rows.map(row => ({
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
phone: row.phone ? `${String(row.phone).slice(0, 3)}****${String(row.phone).slice(-4)}` : null,
|
||||
activeSessionCount: row.active_session_count,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
pass('db.platform_admin_disabled_sessions', 'Disabled platform admin users have no active legacy sessions');
|
||||
}
|
||||
|
||||
const missingAuthSecretRows = await pool.query(`
|
||||
select p.tenant_id, p.provider
|
||||
from public.tenant_auth_providers p
|
||||
|
||||
18
supabase/migrations/202606300006_platform_staff_status.sql
Normal file
18
supabase/migrations/202606300006_platform_staff_status.sql
Normal 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);
|
||||
Reference in New Issue
Block a user