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

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

View File

@@ -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],

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> : {};
}
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');