forked from wangziqi/gongxue-base
feat: add platform staff management
This commit is contained in:
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user