Commit 1b133cc2 by luoqi

Merge FridayAI onboarding and host mappings into test

parents a66a3883 cb08cf0e
Pipeline #3757 failed in 0 seconds
......@@ -33,6 +33,7 @@
*/
export const PERMISSIONS = {
PLATFORM_VIEW: 'platform.view',
PLATFORM_TENANT_MANAGE: 'platform.tenant.manage',
PLATFORM_HOSTING_MANAGE: 'platform.hosting.manage',
PLATFORM_AUDIT_VIEW: 'platform.audit.view',
APPOINTMENT_CREATE: 'appointment.create',
......@@ -149,7 +150,7 @@ export const ADMIN_PERMISSIONS: readonly Permission[] = [
* 未知角色的正确处理是**什么都不给**,见 `permissionsOf`。
*/
const ROLE_PERMISSIONS: Record<string, readonly Permission[]> = {
platform_admin: [PERMISSIONS.PLATFORM_VIEW, PERMISSIONS.PLATFORM_HOSTING_MANAGE, PERMISSIONS.PLATFORM_AUDIT_VIEW],
platform_admin: [PERMISSIONS.PLATFORM_VIEW, PERMISSIONS.PLATFORM_TENANT_MANAGE, PERMISSIONS.PLATFORM_HOSTING_MANAGE, PERMISSIONS.PLATFORM_AUDIT_VIEW],
platform_viewer: [PERMISSIONS.PLATFORM_VIEW, PERMISSIONS.PLATFORM_AUDIT_VIEW],
owner: [
PERMISSIONS.APPOINTMENT_CREATE,
......
......@@ -86,7 +86,16 @@ export class IntegrationPolicyService {
throw new BizError(12804, '集团未开通或已停用');
const parsed = tenantPolicySchema.safeParse(row.integrationPolicy);
if (!parsed.success) throw new BizError(12804, '集团接入规则未就绪');
const policy = parsed.data;
// 平台新开户沿用宿主统一能力;旧客户的显式限制保持原样。
const policy = parsed.data.inheritAppPolicy
? {
...parsed.data,
allowedRoles: app.allowedRoles,
capabilities: app.capabilities,
modules: app.modules,
maxSessionSeconds: app.maxSessionSeconds,
}
: parsed.data;
const nodes = await db.orgUnit.findMany({
where: { tenantId: id },
select: { id: true, parentId: true, isActive: true, kind: true },
......@@ -260,9 +269,13 @@ export class IntegrationPolicyService {
}
async context(session: AuthSession, db: IntegrationDb = this.prisma): Promise<HostScope | null> {
if (session.source !== 'host') return null;
if (!session.tenantId || session.scope === 'platform') throw new BizError(12810, '宿主会话范围无效');
const roleGrant = await db.userRoleGrant.findUnique({where:{userId_scope:{userId:session.userId,scope:'tenant'}}});
if (!roleGrant || roleGrant.tenantId !== session.tenantId) throw new BizError(12810, '角色授权已撤销');
if (!session.tenantId || session.scope === 'platform')
throw new BizError(12810, '宿主会话范围无效');
const roleGrant = await db.userRoleGrant.findUnique({
where: { userId_scope: { userId: session.userId, scope: 'tenant' } },
});
if (!roleGrant || roleGrant.tenantId !== session.tenantId)
throw new BizError(12810, '角色授权已撤销');
const ctx = session.integrationContext as unknown as HostSessionContext;
if (ctx?.version !== 2 || !ctx.appId || !ctx.launchId)
throw new BizError(12810, '旧宿主会话已结束,请重新进入');
......
......@@ -17,7 +17,11 @@ export const BASIC_WRITE = [
'channel.bind',
'appointment.task.process',
] as const;
export const CAPABILITIES = [...BASIC_READ, ...BASIC_WRITE, ...Object.values(PERMISSIONS).filter((p) => !p.startsWith('platform.'))] as const;
export const CAPABILITIES = [
...BASIC_READ,
...BASIC_WRITE,
...Object.values(PERMISSIONS).filter((p) => !p.startsWith('platform.')),
] as const;
const BASE_BY_ROLE: Record<string, readonly string[]> = {
viewer: BASIC_READ,
staff: [...BASIC_READ, ...BASIC_WRITE],
......@@ -112,6 +116,7 @@ export const appSchema = z
.strict();
export const tenantPolicySchema = z
.object({
inheritAppPolicy: z.boolean().optional(),
version: z.literal(2),
rootOrgId: z.string().uuid(),
modules: z.array(moduleName).min(1).max(2).default(['workbench', 'appointment-confirm']),
......
import { z } from 'zod';
import { sourceId } from '../integration/integration.schema';
export const onboardingSchema = z
.object({
name: z.string().trim().min(2).max(64),
partnerCode: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,63}$/),
partnerTenantId: sourceId,
partnerOrgRootRef: sourceId,
sourceUserId: sourceId.optional(),
ownerName: z.string().trim().min(2).max(32),
reason: z.string().trim().min(1).max(500),
})
.strict();
export type OnboardingInput = z.infer<typeof onboardingSchema>;
export const ownerMappingSchema = z
.object({
sourceUserId: sourceId,
reason: z.string().trim().min(1).max(500),
})
.strict();
export type OwnerMappingInput = z.infer<typeof ownerMappingSchema>;
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import { createHash, randomBytes } from 'node:crypto';
import { Prisma } from '../../../generated/prisma';
import { PrismaService } from '../../prisma/prisma.service';
import type { PlatformUser } from '../../common/guards/jwt-auth.guard';
import { setTenantRole } from '../../common/role-grants';
import { identityProvider, tenantPolicySchema } from '../integration/integration.schema';
import { PacService } from '../pac/pac.service';
import type { OnboardingInput, OwnerMappingInput } from './platform-onboarding.schema';
// 与员工开户相同:凭据仅返回一次,数据库和审计只保存摘要。
const ACTIVATION_TTL_MS = 30 * 60_000;
type Db = Pick<Prisma.TransactionClient, 'tenant' | 'orgUnitRef' | 'integrationApp'>;
@Injectable()
export class PlatformOnboardingService {
constructor(
private readonly db: PrismaService,
private readonly pac: PacService,
) {}
async applications() {
return this.db.integrationApp.findMany({
where: { enabled: true },
orderBy: { appId: 'asc' },
select: {
appId: true,
hostCode: true,
allowedRoles: true,
capabilities: true,
modules: true,
},
});
}
private async check(db: Db, input: OnboardingInput) {
const tenants = await db.tenant.findMany({
where: { partnerCode: input.partnerCode, partnerTenantId: input.partnerTenantId },
select: { id: true, partnerOrgRootRef: true, integrationPolicy: true },
});
if (tenants.some((t) => t.partnerOrgRootRef === input.partnerOrgRootRef))
throw new ConflictException('这个客户已开户,请返回列表管理,不能重复开通');
const refs = [input.partnerOrgRootRef];
const occupied = await db.orgUnitRef.findFirst({
where: {
tenantId: { in: tenants.map((t) => t.id) },
hostCode: input.partnerCode,
externalId: { in: refs },
},
select: { externalId: true },
});
if (
occupied ||
tenants.some(
(t) =>
refs.includes(t.partnerOrgRootRef) ||
Object.keys(
tenantPolicySchema.safeParse(t.integrationPolicy).data?.organizationMappings ?? {},
).some((r) => refs.includes(r)),
)
)
throw new ConflictException('组织编号已关联其他客户,请核对品牌根和门店编号');
}
async preview(input: OnboardingInput) {
await this.check(this.db, input);
const app = await this.hostApp(this.db, input.partnerCode);
if (input.sourceUserId && !app)
throw new BadRequestException('此宿主尚未接入;可以暂不填写宿主用户 ID,开户后再补映射');
// 只读取组织字典;平台开户人员不读取患者。空字典不等于编号核验通过。
let dataStatus: 'verified' | 'empty' | 'unavailable' = 'unavailable';
const warnings: string[] = [];
if (!app) warnings.push('宿主尚未接入,当前可独立登录;接入后可补充宿主账号映射。');
else if (input.sourceUserId && !app.allowedRoles.includes('admin'))
warnings.push(
'账号映射会保存;该宿主当前尚未开放管理员角色,内嵌管理员权限需在宿主接入配置中统一开放。',
);
try {
const nodes = await this.pac.orgNodes({
host: input.partnerCode,
tenantId: input.partnerTenantId,
rootRef: input.partnerOrgRootRef,
});
if (!nodes.length) {
dataStatus = 'empty';
warnings.push(
'PAC 暂无该客户的组织数据;开户后仍需核对数据接入,不能据此认为患者数据已就绪。',
);
} else {
dataStatus = 'verified';
}
} catch (e) {
if (e instanceof BadRequestException) throw e;
warnings.push('暂时无法核验 PAC 组织数据;可以先开户,患者数据关联需后续核对。');
}
return { dataStatus, warnings, ownerRole: 'owner', ownerScope: 'tenant' };
}
async create(actor: PlatformUser, input: OnboardingInput) {
// 提交时重新检查,不信任浏览器的预览结果。
const preview = await this.preview(input);
try {
const result = await this.db.$transaction(
async (tx) => {
// 同一宿主数据空间串行开户,防止并发把同一编号分配到两个客户。
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`ai-onboard:${input.partnerCode}:${input.partnerTenantId}`}, 0))::text`;
await this.check(tx, input);
const tenant = await tx.tenant.create({
data: {
name: input.name,
partnerCode: input.partnerCode,
partnerTenantId: input.partnerTenantId,
partnerOrgRootRef: input.partnerOrgRootRef,
},
select: { id: true, name: true },
});
const root = await tx.orgUnit.create({
data: { tenantId: tenant.id, name: input.name, kind: 'brand' },
});
const owner = await tx.user.create({
data: {
tenantId: tenant.id,
displayName: input.ownerName,
role: 'owner',
dataScope: 'tenant',
},
select: { id: true, displayName: true },
});
await setTenantRole(tx, tenant.id, owner.id, 'owner', 'manual', actor.id);
const app = await this.hostApp(tx, input.partnerCode);
if (app) await this.attachHost(tx, tenant.id, root.id, input.partnerOrgRootRef, app);
if (input.sourceUserId) {
if (!app) throw new BadRequestException('宿主应用已变更,请重新核对');
await tx.userIdentity.create({
data: {
userId: owner.id,
provider: identityProvider(app.appId, tenant.id),
externalId: input.sourceUserId,
},
});
}
const activation = await this.activation(tx, tenant.id, owner.id, actor.id);
await tx.auditLog.create({
data: {
scope: 'platform',
tenantId: tenant.id,
action: 'tenant.onboard',
objectType: 'tenant',
objectId: tenant.id,
actorType: 'human',
actorUserId: actor.id,
result: 'ok',
changes: {
reason: input.reason,
name: input.name,
partnerCode: input.partnerCode,
partnerTenantId: input.partnerTenantId,
partnerOrgRootRef: input.partnerOrgRootRef,
ownerId: owner.id,
loginMode: 'activation',
rootOrgId: root.id,
dataStatus: preview.dataStatus,
},
},
});
return { tenant, owner, activation };
},
{ timeout: 15000 },
);
return { ...result, warnings: preview.warnings };
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002')
throw new ConflictException('客户或组织编号已存在,请刷新列表核对开户结果');
throw e;
}
}
private async hostApp(db: Pick<Prisma.TransactionClient, 'integrationApp'>, host: string) {
const apps = await db.integrationApp.findMany({ where: { hostCode: host, enabled: true } });
if (apps.length > 1)
throw new BadRequestException(
'此宿主存在多个有效应用,需先由接入人员明确应用归属;不能猜测账号映射',
);
return apps[0] ?? null;
}
private async attachHost(
tx: Prisma.TransactionClient,
tenantId: string,
rootId: string,
rootRef: string,
app: NonNullable<Awaited<ReturnType<PlatformOnboardingService['hostApp']>>>,
) {
await tx.tenant.update({
where: { id: tenantId },
data: {
integrationAppId: app.appId,
integrationEnabled: true,
integrationPolicy: tenantPolicySchema.parse({
version: 2,
rootOrgId: rootId,
organizationMappings: { [rootRef]: rootId },
inheritAppPolicy: true,
allowedRoles: app.allowedRoles,
capabilities: app.capabilities,
modules: app.modules,
maxSessionSeconds: app.maxSessionSeconds,
jitEnabled: true,
}),
},
});
}
async mapOwner(actor: PlatformUser, tenantId: string, ownerId: string, input: OwnerMappingInput) {
return this.db.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM app.tenants WHERE id=${tenantId}::uuid FOR UPDATE`;
const tenant = await tx.tenant.findUnique({ where: { id: tenantId } });
if (!tenant || tenant.status !== 'active')
throw new BadRequestException('客户不存在或已停用');
const app = tenant.integrationAppId
? await tx.integrationApp.findUnique({ where: { appId: tenant.integrationAppId } })
: await this.hostApp(tx, tenant.partnerCode);
if (!app?.enabled || app.hostCode !== tenant.partnerCode)
throw new BadRequestException('此宿主尚未接入,请先完成宿主应用注册;不影响独立登录');
await tx.$queryRaw`SELECT id FROM app.users WHERE id=${ownerId}::uuid FOR UPDATE`;
const owner = await tx.user.findFirst({
where: { id: ownerId, tenantId, isActive: true },
include: { roleGrants: true, identities: true },
});
if (
!owner ||
owner.dataScope !== 'tenant' ||
!owner.roleGrants.some((g) => g.scope === 'tenant' && g.role === 'owner') ||
owner.roleGrants.some((g) => g.scope === 'platform')
)
throw new BadRequestException('请选择本客户全范围的 owner');
const provider = identityProvider(app.appId, tenantId);
const identity = await tx.userIdentity.findUnique({
where: { provider_externalId: { provider, externalId: input.sourceUserId } },
});
if (identity || owner.identities.some((i) => i.provider.startsWith('host:')))
throw new ConflictException('宿主用户或管理员已建立映射,不能覆盖或合并账号');
if (!tenant.integrationAppId) {
const roots = await tx.orgUnit.findMany({
where: { tenantId, parentId: null, isActive: true },
});
if (roots.length !== 1) throw new BadRequestException('请确认唯一的组织顶层节点');
await this.attachHost(tx, tenantId, roots[0]!.id, tenant.partnerOrgRootRef, app);
}
await tx.userIdentity.create({
data: { userId: ownerId, provider, externalId: input.sourceUserId },
});
await tx.auditLog.create({
data: {
scope: 'platform',
tenantId,
action: 'tenant.owner.map',
objectType: 'user',
objectId: ownerId,
actorType: 'human',
actorUserId: actor.id,
result: 'ok',
changes: { reason: input.reason, appId: app.appId },
},
});
return { ok: true };
});
}
private async activation(
tx: Prisma.TransactionClient,
tenantId: string,
userId: string,
actorId: string,
) {
const token = randomBytes(32).toString('base64url');
const expiresAt = new Date(Date.now() + ACTIVATION_TTL_MS);
await tx.userActivation.create({
data: {
tenantId,
userId,
tokenHash: createHash('sha256').update(token).digest('hex'),
expiresAt,
createdBy: actorId,
},
});
return { token, expiresAt: expiresAt.toISOString() };
}
async reissue(actor: PlatformUser, tenantId: string, userId: string, reason: string) {
return this.db.$transaction(async (tx) => {
await tx.$queryRaw`SELECT id FROM app.users WHERE id=${userId}::uuid FOR UPDATE`;
const user = await tx.user.findFirst({
where: { id: userId, tenantId, isActive: true },
include: {
identities: true,
tenant: true,
roleGrants: true,
activations: { select: { userId: true }, take: 1 },
},
});
if (
!user ||
user.tenant?.status !== 'active' ||
user.passwordHash ||
(user.identities.length > 0 && !user.activations.length) ||
!user.roleGrants.some((r) => r.scope === 'tenant' && r.role === 'owner') ||
user.roleGrants.some((r) => r.scope === 'platform')
)
throw new BadRequestException('仅支持尚未激活且已发放激活票的客户管理员');
await tx.userActivation.updateMany({
where: { tenantId, userId, usedAt: null },
data: { usedAt: new Date() },
});
const activation = await this.activation(tx, tenantId, userId, actor.id);
await tx.auditLog.create({
data: {
scope: 'platform',
tenantId,
action: 'tenant.owner.activation',
objectType: 'user',
objectId: userId,
actorType: 'human',
actorUserId: actor.id,
result: 'ok',
changes: { reason },
},
});
return activation;
});
}
}
import { Body, Controller, Get, Param, Post, Req, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Header, Param, Post, Req, UseGuards } from '@nestjs/common';
import { z } from 'zod';
import { ZodValidationException } from 'nestjs-zod';
function input<T extends z.ZodType>(schema: T, value: unknown): z.output<T> {
......@@ -16,6 +16,8 @@ import { LoginThrottleGuard, ThrottleCost } from '../auth/login-throttle.guard';
import { PasswordLoginService } from '../auth/password-login.service';
import { SessionService } from '../auth/session.service';
import { PlatformService } from './platform.service';
import { PlatformOnboardingService } from './platform-onboarding.service';
import { onboardingSchema, ownerMappingSchema } from './platform-onboarding.schema';
@Controller('platform')
@AuthScope('platform')
......@@ -24,6 +26,7 @@ export class PlatformController {
private readonly platform: PlatformService,
private readonly passwords: PasswordLoginService,
private readonly sessions: SessionService,
private readonly onboarding: PlatformOnboardingService,
) {}
@Get('me')
@AllowTempPassword()
......@@ -54,6 +57,58 @@ export class PlatformController {
tenants() {
return this.platform.tenants();
}
@Get('onboarding/apps')
@RequirePermission(PERMISSIONS.PLATFORM_TENANT_MANAGE)
applications() {
return this.onboarding.applications();
}
@Post('onboarding/preview')
@RequirePermission(PERMISSIONS.PLATFORM_TENANT_MANAGE)
preview(@Body() body: unknown) {
return this.onboarding.preview(input(onboardingSchema, body));
}
@Post('tenants')
@Header('Cache-Control', 'no-store')
@RequirePermission(PERMISSIONS.PLATFORM_TENANT_MANAGE)
create(@Req() req: { platformUser: PlatformUser }, @Body() body: unknown) {
return this.onboarding.create(req.platformUser, input(onboardingSchema, body));
}
@Post('tenants/:id/owners/:userId/mapping')
@RequirePermission(PERMISSIONS.PLATFORM_TENANT_MANAGE)
mapOwner(
@Req() req: { platformUser: PlatformUser },
@Param('id') id: string,
@Param('userId') userId: string,
@Body() body: unknown,
) {
return this.onboarding.mapOwner(
req.platformUser,
input(z.uuid(), id),
input(z.uuid(), userId),
input(ownerMappingSchema, body),
);
}
@Post('tenants/:id/owners/:userId/activation')
@Header('Cache-Control', 'no-store')
@RequirePermission(PERMISSIONS.PLATFORM_TENANT_MANAGE)
activation(
@Req() req: { platformUser: PlatformUser },
@Param('id') id: string,
@Param('userId') userId: string,
@Body() body: unknown,
) {
const b = input(z.object({ reason: z.string().trim().min(1).max(500) }).strict(), body);
return this.onboarding.reissue(
req.platformUser,
input(z.uuid(), id),
input(z.uuid(), userId),
b.reason,
);
}
@Get('tenants/:id')
@RequirePermission(PERMISSIONS.PLATFORM_VIEW)
detail(@Param('id') id: string) {
......
......@@ -3,9 +3,17 @@ import { Module } from '@nestjs/common';
import { PlatformController } from './platform.controller';
import { PlatformService } from './platform.service';
import { HostingAdminService } from '../hosting/hosting-admin.service';
import { PacModule } from '../pac/pac.module';
import { PlatformOnboardingService } from './platform-onboarding.service';
@Module({
imports: [PacModule],
controllers: [PlatformController],
providers: [PlatformAdminService, PlatformService, HostingAdminService],
providers: [
PlatformAdminService,
PlatformService,
HostingAdminService,
PlatformOnboardingService,
],
exports: [HostingAdminService],
})
export class PlatformModule {}
......@@ -8,6 +8,7 @@ import { Prisma } from '../../../generated/prisma';
import { PrismaService } from '../../prisma/prisma.service';
import { HostingAdminService } from '../hosting/hosting-admin.service';
import type { PlatformUser } from '../../common/guards/jwt-auth.guard';
import { identityProvider } from '../integration/integration.schema';
import { maskPhone } from '../../common/mask';
@Injectable()
......@@ -64,7 +65,18 @@ export class PlatformService {
const users = await this.db.user.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
select: { id: true, displayName: true, phone: true, role: true, isActive: true },
select: {
id: true,
displayName: true,
phone: true,
role: true,
isActive: true,
passwordHash: true,
dataScope: true,
identities: { select: { provider: true, externalId: true } },
activations: { select: { userId: true }, take: 1 },
roleGrants: { select: { scope: true } },
},
});
const accounts = await this.db.wecomAccount.findMany({
where: { tenantId },
......@@ -76,7 +88,27 @@ export class PlatformService {
});
return {
tenant,
users: users.map((u) => ({ ...u, phone: u.phone ? maskPhone(u.phone) : null })),
users: users.map(({ passwordHash, identities, roleGrants, activations, ...u }) => ({
...u,
phone: u.phone ? maskPhone(u.phone) : null,
sourceUserId: tenant.integrationAppId
? (identities.find(
(i) => i.provider === identityProvider(tenant.integrationAppId!, tenantId),
)?.externalId ?? null)
: null,
canMapOwner:
u.role === 'owner' &&
u.dataScope === 'tenant' &&
u.isActive &&
!identities.some((i) => i.provider.startsWith('host:')) &&
!roleGrants.some((g) => g.scope === 'platform'),
pendingActivation:
u.role === 'owner' &&
u.isActive &&
!passwordHash &&
(!identities.length || activations.length > 0) &&
!roleGrants.some((g) => g.scope === 'platform'),
})),
accounts,
seats,
};
......
......@@ -20,6 +20,9 @@ export interface AdminCan {
}
export interface AdminUserRow {
sourceUserId?: string | null;
canMapHostUser?: boolean;
canReissueActivation?: boolean;
id: string;
displayName: string;
role: string;
......
......@@ -211,6 +211,7 @@ export class ContactController {
anchors: readAnchors(b.anchors),
dryRun: readBool(b.dryRun, 'dryRun'),
rev: b.rev,
sourceUserId: b.sourceUserId,
});
}
......
import { Prisma } from '../../../generated/prisma';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { AiApiCode } from '../../common/errors/api-codes';
import { BizError } from '../../common/errors/biz-error';
import { hasPermission, PERMISSIONS } from '../../common/permissions';
import { identityProvider, tenantPolicySchema } from '../integration/integration.schema';
export function readSourceUserId(value: unknown): string | undefined {
if (value === undefined || value === '') return undefined;
if (typeof value !== 'string' || !value.trim() || value.trim().length > 128 || /[\s\x00-\x1f]/.test(value.trim()))
throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '宿主用户 ID 请填写不含空格的有效编号,最多 128 个字符');
return value.trim();
}
export function canMapHostUser(op: AuthUser): boolean {
return hasPermission(op, PERMISSIONS.USER_PROVISION) &&
op.dataScope === 'tenant' && (!op.hostScope || op.hostScope.wholeTenant);
}
/** 调用者持有租户写锁;只建立关系,不覆盖身份,也不改变角色或数据范围。 */
export async function prepareHostMapping(db: Prisma.TransactionClient, op: AuthUser, sourceUserId: string) {
if (!canMapHostUser(op))
throw new BizError(AiApiCode.AUTH_PERMISSION_DENIED, '关联宿主账号需要本客户全范围管理员权限');
const tenant = await db.tenant.findUnique({ where: { id: op.tenantId } });
const app = tenant?.integrationAppId
? await db.integrationApp.findUnique({ where: { appId: tenant.integrationAppId } }) : null;
if (!tenant || tenant.status !== 'active' || !tenant.integrationEnabled || !app?.enabled ||
app.hostCode !== tenant.partnerCode || !tenantPolicySchema.safeParse(tenant.integrationPolicy).success)
throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '本客户尚未接入宿主,可先不填写用户 ID,接入后再关联');
const provider = identityProvider(app.appId, tenant.id);
if (await db.userIdentity.findUnique({ where: { provider_externalId: { provider, externalId: sourceUserId } } }))
throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '这个宿主用户 ID 已关联账号,请使用已有账号,不能重复开户或覆盖');
return provider;
}
export function mappingConflict(error: unknown): never {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002')
throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '账号映射已发生变化,请刷新后核对,不能重复关联');
throw error;
}
import { tenantPolicySchema } from '../integration/integration.schema';
import { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '../../../generated/prisma';
import { AiApiCode } from '../../common/errors/api-codes';
......@@ -314,6 +315,14 @@ export class OrgAdminService {
data: { tenantId: op.tenantId, orgUnitId: id, hostCode: snap.hostCode, externalId: to },
});
}
// 旧开户可能在免登策略里保存过门店映射。修改后清除该节点旧项,统一读 orgUnitRef。
// 客户根编号承担租户路由,保留根节点的开户标识。
const tenant = await tx.tenant.findUniqueOrThrow({ where: { id: op.tenantId } });
const policy = tenantPolicySchema.safeParse(tenant.integrationPolicy);
if (policy.success && policy.data.rootOrgId !== id) {
const organizationMappings = Object.fromEntries(Object.entries(policy.data.organizationMappings).filter(([, nodeId]) => nodeId !== id));
await tx.tenant.update({ where: { id: op.tenantId }, data: { integrationPolicy: { ...policy.data, organizationMappings } } });
}
const rev = await bumpOrgRev(tx, op.tenantId);
await audit(tx, op, to ? 'org.ref.set' : 'org.ref.clear', 'org_unit', id, {
nodeId: id,
......
import { prepareHostMapping, readSourceUserId, mappingConflict } from './host-user-mapping';
import { Injectable, Logger } from '@nestjs/common';
import { createHash, randomBytes } from 'node:crypto';
import type { Prisma } from '../../../generated/prisma';
......@@ -90,6 +91,7 @@ export class ProvisionService {
*/
input: {
contactId?: string;
sourceUserId?: unknown;
displayName?: string;
role: string;
/** 旧前端:勾的节点,一律含下级 */
......@@ -102,6 +104,7 @@ export class ProvisionService {
rev?: unknown;
},
): Promise<ProvisionPreview | ProvisionResult> {
const sourceUserId = readSourceUserId(input.sourceUserId);
const role = input.role === 'staff' || input.role === 'viewer' ? input.role : null;
// ⚠️ ⛔ 不允许开 owner:那等于把提权通道复制一份出去。要加 owner 走 CLI(人得想一下)
if (!role) {
......@@ -160,6 +163,7 @@ export class ProvisionService {
if (input.dryRun) {
return runTx(this.prisma, async (tx) => {
const rev = await shareLockOrg(tx, admin.tenantId);
if (sourceUserId) await prepareHostMapping(tx, admin, sourceUserId);
const { next, nodes } = await this.planGrants(tx, admin, anchors);
const vis = visibleOf(nodes, next);
return {
......@@ -185,6 +189,7 @@ export class ProvisionService {
// ⚠️ 先锁再重算授权 —— ⛔ 信预演的结果(预演之后别人可能改过树或他的授权)
const cur = await lockOrg(tx, admin.tenantId);
if (input.rev !== undefined) assertRev(cur, input.rev);
const provider = sourceUserId ? await prepareHostMapping(tx, admin, sourceUserId) : null;
const { next } = await this.planGrants(tx, admin, anchors);
const u = await tx.user.create({
data: {
......@@ -197,6 +202,7 @@ export class ProvisionService {
},
select: { id: true },
});
if (provider && sourceUserId) await tx.userIdentity.create({ data: { provider, externalId: sourceUserId, userId: u.id } });
if (next.length) {
await tx.userOrgScope.createMany({
data: next.map((a) => ({
......@@ -242,11 +248,11 @@ export class ProvisionService {
actorType: 'human',
actorUserId: admin.id,
// ⛔ `changes` 里**不许有票的明文** —— 它是凭据
changes: { displayName, role, vid: contact?.vid ?? null, orgUnitIds: next.map((a) => a.orgUnitId), anchors: next.map((a) => ({ orgUnitId: a.orgUnitId, includeSubtree: a.includeSubtree })), rev },
changes: { hostUserMapped: !!sourceUserId, displayName, role, vid: contact?.vid ?? null, orgUnitIds: next.map((a) => a.orgUnitId), anchors: next.map((a) => ({ orgUnitId: a.orgUnitId, includeSubtree: a.includeSubtree })), rev },
},
});
return { userId: u.id, rev, granted: next.length };
});
}).catch(mappingConflict);
this.logger.log(`开户 ${displayName}(${role}) by=${admin.displayName} 授权=${granted} 条`);
return { applied: true, rev, userId, displayName, token, expiresAt: expiresAt.toISOString() };
......
import { canMapHostUser, prepareHostMapping, readSourceUserId, mappingConflict } from './host-user-mapping';
import { identityProvider } from '../integration/integration.schema';
import { setTenantRole } from '../../common/role-grants';
import { IntegrationPolicyService } from '../integration/integration-policy.service';
import { Injectable, Logger, Optional } from '@nestjs/common';
......@@ -50,6 +52,7 @@ const FILTERS = ['all', 'unactivated', 'ungranted', 'tenant'] as const;
*/
const AUDIT_ACTIONS = [
'user.provision',
'user.host.map',
'user.activate',
'user.activation.reissue',
'user.scope.set',
......@@ -161,6 +164,29 @@ export class UserAdminService {
};
}
async mapHostUser(op: AuthUser, id: string, raw: unknown) {
if (!canMapHostUser(op)) throw new BizError(AiApiCode.AUTH_PERMISSION_DENIED, '关联源系统账号需要本客户全范围管理员权限');
const sourceUserId = readSourceUserId(raw);
if (!sourceUserId) throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '请填写源系统用户 ID');
if (!isUuid(id)) throw new BizError(AiApiCode.USER_NOT_FOUND, '找不到这个员工');
return runTx(this.prisma, async (tx) => {
await lockOrg(tx, op.tenantId);
const snap = await loadOrgSnapshot(tx, op.tenantId);
const { reach } = await reachOf(tx, op, snap.nodes);
await tx.$queryRaw`SELECT id FROM app.users WHERE id=${id}::uuid FOR UPDATE`;
const target = await this.mustManage(tx, op, reach, id, 'role');
if (!['staff', 'viewer'].includes(target.role) ||
await tx.userRoleGrant.findUnique({ where: { userId_scope: { userId: id, scope: 'platform' } } }))
throw new BizError(AiApiCode.AUTH_PERMISSION_DENIED, '这里只能关联本客户的员工或只读账号');
if (await tx.userIdentity.findFirst({ where: { userId: id, provider: { startsWith: 'host:' } } }))
throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '此员工已关联源系统账号,不能覆盖或合并');
const provider = await prepareHostMapping(tx, op, sourceUserId);
await tx.userIdentity.create({ data: { provider, externalId: sourceUserId, userId: id } });
await audit(tx, op, 'user.host.map', 'user', id, { mapped: true });
return { sourceUserId };
}).catch(mappingConflict);
}
async setScopes(op: AuthUser, id: string, body: Record<string, unknown>): Promise<ScopeChangeResult> {
if (!hasPermission(op, PERMISSIONS.USER_SCOPE_GRANT)) {
throw new BizError(AiApiCode.AUTH_PERMISSION_DENIED, '你没有改授权的权限');
......@@ -378,6 +404,8 @@ export class UserAdminService {
try {
await runTx(this.prisma, async (tx) => {
await tx.$queryRaw`SELECT id FROM app.users WHERE id=${id}::uuid FOR UPDATE`;
const current = await this.mustManage(tx, op, reach, id, 'activation');
if (current.activated) throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '账号已激活,不能重发激活链接');
if (await tx.userRoleGrant.findUnique({where:{userId_scope:{userId:id,scope:'platform'}}})) throw new BizError(AiApiCode.AUTH_PERMISSION_DENIED, '平台账号由平台管理');
// ⚠️ 先作废旧票再建新票:uq_ua_live 只许一张没用过的票
const revoked = await tx.userActivation.updateMany({
......@@ -435,7 +463,9 @@ export class UserAdminService {
if (what === 'activation' && await db.userRoleGrant.findUnique({where:{userId_scope:{userId:id,scope:'platform'}}})) throw new BizError(AiApiCode.AUTH_PERMISSION_DENIED, '平台账号由平台管理');
if (!u.isActive) throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '这个账号已停用');
const { passwordHash, ...rest } = u;
return { ...rest, tenantId: u.tenantId, activated: !!passwordHash || !!(this.integration && (await this.integration.hostLoginUsers(op.tenantId, [u.id], db)).has(u.id)), anchors };
const hostLogin = !!(this.integration && (await this.integration.hostLoginUsers(op.tenantId, [u.id], db)).has(u.id));
const originalTicket = what === 'activation' && hostLogin ? await db.userActivation.findFirst({ where: { userId: id, tenantId: op.tenantId }, select: { userId: true } }) : null;
return { ...rest, tenantId: u.tenantId, activated: !!passwordHash || (hostLogin && !originalTicket), anchors };
}
/**
......@@ -465,6 +495,10 @@ export class UserAdminService {
where: { tenantId: op.tenantId },
select: { id: true, displayName: true, role: true, dataScope: true, isActive: true, passwordHash: true, orgUnitId: true, jobTitle: true, integrationManagedScope: true, integrationManagedRole: true },
});
const tenant = await this.prisma.tenant.findUnique({ where: { id: op.tenantId } });
const hostIdentities = await this.prisma.userIdentity.findMany({ where: { user: { tenantId: op.tenantId }, provider: { startsWith: 'host:' } }, select: { userId: true, provider: true, externalId: true } });
const platformGrants = await this.prisma.userRoleGrant.findMany({ where: { userId: { in: users.map(u => u.id) }, scope: 'platform' }, select: { userId: true } });
const allTickets = await this.prisma.userActivation.findMany({ where: { tenantId: op.tenantId }, select: { userId: true } });
const externalLogins = this.integration ? await this.integration.hostLoginUsers(op.tenantId, users.map((u) => u.id)) : new Set<string>();
const scopes = await this.prisma.userOrgScope.findMany({
where: { tenantId: op.tenantId },
......@@ -520,6 +554,9 @@ export class UserAdminService {
accountCount: accountCount.get(u.id) ?? 0,
self,
manageable,
sourceUserId: hostIdentities.find(i => i.userId === u.id && tenant?.integrationAppId && i.provider === identityProvider(tenant.integrationAppId, op.tenantId))?.externalId ?? null,
canMapHostUser: canMapHostUser(op) && manageable && u.isActive && ['staff', 'viewer'].includes(u.role) && !platformGrants.some(g => g.userId === u.id) && !hostIdentities.some(i => i.userId === u.id),
canReissueActivation: !u.passwordHash && allTickets.some(t => t.userId === u.id) && !platformGrants.some(g => g.userId === u.id),
});
}
return { rows, snap };
......@@ -531,6 +568,8 @@ function summarize(action: string, changes: unknown): string {
const c = (changes ?? {}) as Record<string, unknown>;
const len = (x: unknown) => (Array.isArray(x) ? x.length : 0);
switch (action) {
case 'user.host.map':
return '关联了宿主账号';
case 'user.provision':
return '开户';
case 'user.scope.set':
......
......@@ -62,9 +62,16 @@ export class UserController {
anchors: readAnchors(b.anchors),
dryRun: readBool(b.dryRun, 'dryRun'),
rev: b.rev,
sourceUserId: b.sourceUserId,
});
}
@RequirePermission(PERMISSIONS.USER_PROVISION)
@Post(':id/host-identity')
mapHost(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() body: Record<string, unknown>) {
return this.admin.mapHostUser(user, id, body?.sourceUserId);
}
@RequirePermission(PERMISSIONS.USER_SCOPE_GRANT)
@Put(':id/scopes')
setScopes(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() body: Record<string, unknown>) {
......
......@@ -66,6 +66,46 @@ it('应用/集团角色准入与原有 AI 授权取交集,不接受 owner/lead
expect(tokenRequestSchema.safeParse({...input(),user:{...input().user,role:'owner'}}).success).toBe(false);
expect(tokenRequestSchema.safeParse({...input(),user:{...input().user,role:'leader'}}).success).toBe(false);
});
function brandAdminFixture(){
const f=fixture();
const adminApp:IntegrationAppConfig={...app,allowedRoles:['staff','viewer','admin'],capabilities:roleCapabilities('owner')};
f.tenants[0]!.integrationPolicy=tenantPolicySchema.parse({version:2,rootOrgId:root,allowedRoles:adminApp.allowedRoles,capabilities:adminApp.capabilities});
f.db.user.findFirst=async()=>({id:'u',isActive:true,role:'owner',dataScope:'scoped'});
const u={...input().user,role:'admin' as const,orgScope:['g1'],clinicId:'c1'};
const ctx={version:2 as const,appId:'a',dataTenantId:'space',sourceUserId:'001',launchId:root,rootOrgId:root,scopeOrgIds:[root],orgScope:['g1'],role:'admin' as const,capabilityCeiling:roleCapabilities('owner'),readonlyCeiling:false};
return {...f,adminApp,u,ctx};
}
it('品牌管理员映射 owner,以品牌根进入时可管理整棵组织树,当前诊所只作默认选择',async()=>{
const f=brandAdminFixture(),graph=await f.policy.resolve(f.adminApp,f.u);
expect(f.policy.role(f.adminApp,graph.policy,f.u).role).toBe('owner');
const scope=await f.policy.scope(f.adminApp,graph,'u',f.u,f.ctx);
expect(scope.orgUnitIds).toEqual([root,leaf]);
expect(scope.clinicId).toBe(leaf);
expect(scope.wholeTenant).toBe(true);
expect(scope.capabilities).toEqual(roleCapabilities('owner'));
const reach=hostOperatorReach(scope.orgUnitIds,graph.nodes.map(n=>({...n,name:n.id})));
if(reach.all)throw Error('宿主范围不应绕过组织授权检查');
expect(reach.subtree.has(root)).toBe(true);
});
it('品牌根范围不绕过 AI 常设授权,只有诊所授权时仍看不到上级',async()=>{
const f=brandAdminFixture();
f.db.userOrgScope.findMany=async()=>[{orgUnitId:leaf,includeSubtree:true}];
const graph=await f.policy.resolve(f.adminApp,f.u);
const scope=await f.policy.scope(f.adminApp,graph,'u',f.u,f.ctx);
expect(scope.orgUnitIds).toEqual([leaf]);expect(scope.wholeTenant).toBe(false);
});
it('品牌管理员不能携带其他品牌的当前诊所或混合组织范围',async()=>{
const f=brandAdminFixture(),graph=await f.policy.resolve(f.adminApp,f.u);
await expect(f.policy.scope(f.adminApp,graph,'u',{...f.u,clinicId:'c2'},f.ctx)).rejects.toMatchObject({code:12806});
await expect(f.policy.resolve(f.adminApp,{...f.u,orgScope:['g1','g2']})).rejects.toMatchObject({code:12816});
});
it('SaaS 员工即使绑定 AI owner,也只能访问本次携带的诊所及员工能力',async()=>{
const f=brandAdminFixture(),u={...f.u,role:'staff' as const,orgScope:['c1']};
const graph=await f.policy.resolve(f.adminApp,u);
const scope=await f.policy.scope(f.adminApp,graph,'u',u,{...f.ctx,role:'staff',scopeOrgIds:[leaf],orgScope:['c1'],capabilityCeiling:roleCapabilities('staff')});
expect(scope.orgUnitIds).toEqual([leaf]);expect(scope.wholeTenant).toBe(false);
expect(scope.capabilities).toEqual(roleCapabilities('staff'));
});
it('应用凭据摘要校验从 AI 表读取,Bearer 字符串不能当 JSON secret 使用',async()=>{
const {sha256}=await import('../src/modules/integration/integration.schema');
const registry=new IntegrationRegistryService({integrationApp:{findUnique:async()=>({...app,secretHashes:[sha256('secret')]})}} as never);
......
import { ProvisionService } from '../src/modules/workbench/provision.service';
import { UserAdminService } from '../src/modules/workbench/user-admin.service';
import { OrgAdminService } from '../src/modules/workbench/org-admin.service';
import { ScopeService } from '../src/modules/scope/scope.service';
import type { AuthUser } from '../src/common/guards/jwt-auth.guard';
import { randomUUID, createHash } from 'node:crypto';
import { PrismaClient } from '../generated/prisma';
import { PrismaService } from '../src/prisma/prisma.service';
import { PlatformOnboardingService } from '../src/modules/platform/platform-onboarding.service';
import {
onboardingSchema,
type OnboardingInput,
} from '../src/modules/platform/platform-onboarding.schema';
import { PlatformController } from '../src/modules/platform/platform.controller';
import { PlatformService } from '../src/modules/platform/platform.service';
import { PERMISSIONS, permissionsOf } from '../src/common/permissions';
import { REQUIRE_PERMISSION } from '../src/common/decorators/require-permission.decorator';
import { AUTH_SCOPE } from '../src/common/decorators/auth-scope.decorator';
import type { PlatformUser } from '../src/common/guards/jwt-auth.guard';
import { identityProvider, roleCapabilities } from '../src/modules/integration/integration.schema';
import { IntegrationPolicyService } from '../src/modules/integration/integration-policy.service';
import { adminCan } from '../src/modules/workbench/admin-common';
import { ActivationService } from '../src/modules/auth/activation.service';
import { PasswordService } from '../src/modules/auth/password.service';
it('开户 API 仅平台管理员可访问', () => {
expect(Reflect.getMetadata(AUTH_SCOPE, PlatformController)).toBe('platform');
for (const method of ['create', 'preview', 'applications', 'activation', 'mapOwner'] as const)
expect(Reflect.getMetadata(REQUIRE_PERMISSION, PlatformController.prototype[method])).toEqual([
PERMISSIONS.PLATFORM_TENANT_MANAGE,
]);
expect(permissionsOf('platform_admin')).toContain(PERMISSIONS.PLATFORM_TENANT_MANAGE);
for (const role of ['owner', 'staff', 'viewer', 'platform_viewer'])
expect(permissionsOf(role)).not.toContain(PERMISSIONS.PLATFORM_TENANT_MANAGE);
});
const sample: OnboardingInput = {
name: '开户测试客户',
partnerCode: 'test',
partnerTenantId: 'space',
partnerOrgRootRef: 'brand',
ownerName: '首位管理',
reason: '本地验证',
};
it('开户不接受越权字段、免登配置或门店结构', () => {
expect(onboardingSchema.safeParse(sample).success).toBe(true);
for (const extra of [
{ login: { mode: 'host', appId: 'a', sourceUserId: '1' } },
{ clinics: [] },
{ role: 'platform_admin' },
{ skipPacCheck: true },
{ tenantId: randomUUID() },
])
expect(onboardingSchema.safeParse({ ...sample, ...extra }).success).toBe(false);
expect(
onboardingSchema.safeParse({ ...sample, clinics: [{ name: '错误门店', externalId: 'brand' }] })
.success,
).toBe(false);
expect(
onboardingSchema.safeParse({
...sample,
login: { mode: 'host', appId: 'a', sourceUserId: '1' },
clinics: [],
}).success,
).toBe(false);
});
const run = process.env.AI_PLATFORM_DB_TEST === '1' ? describe : describe.skip;
run('可视化开户(仅本地真实数据库)', () => {
const host = `onboard-${randomUUID()}`,
appId = `onboard-${randomUUID()}`;
let db: PrismaClient,
svc: PlatformOnboardingService,
actor: PlatformUser,
sequence = 0;
const pac = { orgNodes: jest.fn() };
const data = (o: Partial<OnboardingInput> = {}): OnboardingInput => ({
...sample,
partnerCode: host,
partnerTenantId: `space-${++sequence}`,
...o,
});
beforeAll(async () => {
if (!['localhost', '127.0.0.1'].includes(new URL(process.env.AI_DATABASE_URL!).hostname))
throw Error('仅限本地库');
db = new PrismaClient();
const u = await db.user.create({ data: { displayName: '开户测试平台人员', role: 'viewer' } });
actor = { id: u.id } as PlatformUser;
svc = new PlatformOnboardingService(db as PrismaService, pac as never);
await db.integrationApp.create({
data: {
appId,
hostCode: host,
enabled: true,
webBaseUrl: 'https://ai.example.invalid',
secretHashes: ['a'.repeat(64)],
modules: ['workbench'],
allowedRoles: ['staff', 'viewer', 'admin'],
capabilities: roleCapabilities('owner'),
},
});
});
beforeEach(() =>
pac.orgNodes.mockReset().mockResolvedValue([
{ ref: 'brand', name: '品牌', parentRef: null },
{ ref: 'clinic', name: '门店', parentRef: 'brand' },
]),
);
afterAll(async () => {
if (!db) return;
const ids = (
await db.tenant.findMany({ where: { partnerCode: host }, select: { id: true } })
).map((t) => t.id);
await db.userIdentity.deleteMany({ where: { user: { tenantId: { in: ids } } } });
await db.auditLog.deleteMany({
where: { OR: [{ tenantId: { in: ids } }, { actorUserId: actor?.id }] },
});
await db.user.deleteMany({ where: { OR: [{ tenantId: { in: ids } }, { id: actor?.id }] } });
await db.orgUnitRef.deleteMany({ where: { tenantId: { in: ids } } });
await db.orgUnit.deleteMany({ where: { tenantId: { in: ids }, parentId: { not: null } } });
await db.orgUnit.deleteMany({ where: { tenantId: { in: ids } } });
await db.tenant.deleteMany({ where: { id: { in: ids } } });
await db.integrationApp.deleteMany({ where: { appId } });
await db.$disconnect();
});
it('预览无写入;空字典和不可达不冒充通过', async () => {
const input = data();
expect((await svc.preview(input)).dataStatus).toBe('verified');
expect(await db.tenant.count({ where: { partnerCode: host } })).toBe(0);
pac.orgNodes.mockResolvedValueOnce([]);
expect((await svc.preview(input)).dataStatus).toBe('empty');
pac.orgNodes.mockRejectedValueOnce(Error('offline'));
expect((await svc.preview(input)).dataStatus).toBe('unavailable');
});
it('创建全客户 owner、树、激活票和审计,业务开关关闭;可重发但不能重置已激活账号', async () => {
const input = data(),
r = await svc.create(actor, input);
const user = await db.user.findUniqueOrThrow({
where: { id: r.owner.id },
include: { roleGrants: true },
});
expect(user).toMatchObject({
role: 'owner',
dataScope: 'tenant',
phone: null,
passwordHash: null,
});
expect(user.roleGrants).toEqual([
expect.objectContaining({ scope: 'tenant', role: 'owner', source: 'manual' }),
]);
expect(adminCan({ ...user, permissions: permissionsOf('owner') } as never).editTree).toBe(true);
expect(await db.orgUnit.count({ where: { tenantId: r.tenant.id } })).toBe(1);
expect(await db.tenant.findUniqueOrThrow({ where: { id: r.tenant.id } })).toMatchObject({
aiAutoSend: false,
integrationEnabled: true,
});
expect(await db.hostingSeat.count({ where: { tenantId: r.tenant.id } })).toBe(0);
const ticket = await db.userActivation.findFirstOrThrow({ where: { userId: user.id } });
expect(ticket.tokenHash).toBe(createHash('sha256').update(r.activation!.token).digest('hex'));
const logs = await db.auditLog.findMany({ where: { tenantId: r.tenant.id } });
expect(logs[0]).toMatchObject({
scope: 'platform',
actorUserId: actor.id,
action: 'tenant.onboard',
});
expect(JSON.stringify(logs)).not.toContain(r.activation!.token);
const detail = await new PlatformService(db as PrismaService, {} as never).detail(r.tenant.id);
expect(detail.users[0]).toMatchObject({ pendingActivation: true });
expect(detail.users[0]).not.toHaveProperty('passwordHash');
const next = await svc.reissue(actor, r.tenant.id, user.id, '测试重发');
expect(
(await db.userActivation.findUniqueOrThrow({ where: { tokenHash: ticket.tokenHash } }))
.usedAt,
).not.toBeNull();
const activation = new ActivationService(db as PrismaService, new PasswordService());
await expect(activation.peek(r.activation!.token)).rejects.toThrow();
expect(await activation.peek(next.token)).toMatchObject({ displayName: input.ownerName });
await activation.activate(next.token, {
phone: `139${String(Date.now()).slice(-8)}`,
password: 'Onboard-Local-827!a',
});
await expect(svc.reissue(actor, r.tenant.id, user.id, '禁止重置')).rejects.toThrow('仅支持');
await expect(activation.peek(next.token)).rejects.toThrow();
});
it('首位 owner 可提前建立源账号关系;激活独立登录和内嵌使用同一身份', async () => {
const input = data({ sourceUserId: 'source-owner' }),
r = await svc.create(actor, input);
const next = await svc.reissue(actor, r.tenant.id, r.owner.id, '已映射仍可激活');
const activation = new ActivationService(db as PrismaService, new PasswordService());
await activation.activate(next.token, {
phone: `138${String(Date.now()).slice(-8)}`,
password: 'Onboard-Local-827!a',
});
const root = await db.orgUnit.findFirstOrThrow({
where: { tenantId: r.tenant.id, parentId: null },
});
const clinic = await db.orgUnit.create({
data: { tenantId: r.tenant.id, parentId: root.id, name: '后补门店', kind: 'clinic' },
});
await db.orgUnitRef.create({
data: { tenantId: r.tenant.id, orgUnitId: clinic.id, hostCode: host, externalId: 'clinic' },
});
expect(
await db.userIdentity.findUniqueOrThrow({
where: {
provider_externalId: {
provider: identityProvider(appId, r.tenant.id),
externalId: 'source-owner',
},
},
}),
).toMatchObject({ userId: r.owner.id });
const detail = await new PlatformService(db as PrismaService, {} as never).detail(r.tenant.id);
expect(detail.users[0]).toMatchObject({
sourceUserId: 'source-owner',
canMapOwner: false,
pendingActivation: false,
});
const app = await db.integrationApp.findUniqueOrThrow({ where: { appId } }),
policy = new IntegrationPolicyService(db as PrismaService, {} as never);
const u = {
userId: 'source-owner',
tenantId: input.partnerTenantId,
role: 'admin' as const,
orgScope: ['brand'],
clinicId: 'clinic',
},
graph = await policy.resolve(app as never, u);
const scope = await policy.scope(app as never, graph, r.owner.id, u, {
version: 2,
appId,
dataTenantId: input.partnerTenantId,
sourceUserId: 'source-owner',
launchId: randomUUID(),
rootOrgId: graph.policy.rootOrgId,
scopeOrgIds: [graph.policy.rootOrgId],
orgScope: ['brand'],
role: 'admin',
capabilityCeiling: roleCapabilities('owner'),
readonlyCeiling: false,
});
expect(scope.wholeTenant).toBe(true);
expect(scope.accountAll).toBe(true);
expect(scope.orgUnitIds).toHaveLength(2);
await expect(svc.reissue(actor, r.tenant.id, r.owner.id, '禁止切换')).rejects.toThrow('仅支持');
});
it('映射可后补,跨客户和重复映射不能覆盖;宿主权限统一生效', async () => {
const r = await svc.create(actor, data());
const input = { sourceUserId: 'later-owner', reason: '后补映射' };
await expect(svc.mapOwner(actor, r.tenant.id, actor.id, input)).rejects.toThrow('owner');
await svc.mapOwner(actor, r.tenant.id, r.owner.id, input);
await expect(svc.mapOwner(actor, r.tenant.id, r.owner.id, input)).rejects.toThrow('不能覆盖');
const policy = new IntegrationPolicyService(db as PrismaService, {} as never);
const app = await db.integrationApp.findUniqueOrThrow({ where: { appId } });
const tenant = await db.tenant.findUniqueOrThrow({ where: { id: r.tenant.id } });
const restricted = { ...app, allowedRoles: ['staff'], capabilities: ['workspace.read'] };
const graph = await policy.byId(restricted as never, tenant.id, tenant.partnerTenantId);
expect(graph.policy.allowedRoles).toEqual(['staff']);
expect(graph.policy.capabilities).toEqual(['workspace.read']);
await db.tenant.update({
where: { id: tenant.id },
data: {
integrationPolicy: { ...(tenant.integrationPolicy as object), inheritAppPolicy: false },
},
});
const explicit = await policy.byId(restricted as never, tenant.id, tenant.partnerTenantId);
expect(explicit.policy.allowedRoles).toContain('admin');
});
it('无宿主应用仍能开户;带映射必须有唯一宿主应用', async () => {
await db.integrationApp.update({ where: { appId }, data: { enabled: false } });
try {
const r = await svc.create(actor, data());
expect(r.activation).toBeTruthy();
await expect(svc.create(actor, data({ sourceUserId: 'x' }))).rejects.toThrow('尚未接入');
} finally {
await db.integrationApp.update({ where: { appId }, data: { enabled: true } });
}
});
it('并发提交只开户一次,不重复创建、不接受其他客户已占用编号', async () => {
const input = data(),
results = await Promise.allSettled([svc.create(actor, input), svc.create(actor, input)]);
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1);
expect(
await db.tenant.count({
where: { partnerCode: host, partnerTenantId: input.partnerTenantId },
}),
).toBe(1);
await expect(svc.create(actor, { ...input, name: '另一个客户' })).rejects.toThrow('已开户');
const existing = await db.tenant.findFirstOrThrow({
where: { partnerCode: host, partnerTenantId: input.partnerTenantId },
});
const root = await db.orgUnit.findFirstOrThrow({
where: { tenantId: existing.id, parentId: null },
});
await db.orgUnitRef.create({
data: {
tenantId: existing.id,
orgUnitId: root.id,
hostCode: host,
externalId: 'other-brand',
},
});
await expect(svc.create(actor, { ...input, partnerOrgRootRef: 'other-brand' })).rejects.toThrow(
'其他客户',
);
});
async function ownerTools() {
const input = data(), created = await svc.create(actor, input);
const tenantId = created.tenant.id;
const user = await db.user.findUniqueOrThrow({ where: { id: created.owner.id } });
const op = { ...user, tenantId, permissions: permissionsOf('owner'), mustChangePassword: false } as AuthUser;
const scope = new ScopeService(db as never);
const policy = new IntegrationPolicyService(db as never, { get: () => db.integrationApp.findUnique({ where: { appId } }) } as never);
return { input, tenantId, op, policy, root: await db.orgUnit.findFirstOrThrow({ where: { tenantId, parentId: null } }),
provision: new ProvisionService(db as never, scope),
users: new UserAdminService(db as never, scope, {} as never, {} as never, policy),
};
}
it('owner 开员工可同时映射、预演不写库、映射后仍可重发独立激活链接', async () => {
const t = await ownerTools();
const body = { displayName: '映射员工', role: 'staff', sourceUserId: 'staff-123', anchors: [{ orgUnitId: t.root.id, includeSubtree: true }] };
const preview = await t.provision.openAccount(t.op, { ...body, dryRun: true });
expect(preview.applied).toBe(false);
expect(await db.userIdentity.count({ where: { provider: identityProvider(appId, t.tenantId) } })).toBe(0);
const r = await t.provision.openAccount(t.op, { ...body, rev: preview.rev });
if (!r.applied) throw Error('未开户');
expect(await db.userIdentity.findUniqueOrThrow({ where: { provider_externalId: { provider: identityProvider(appId, t.tenantId), externalId: 'staff-123' } } })).toMatchObject({ userId: r.userId });
expect(await db.user.findUniqueOrThrow({ where: { id: r.userId } })).toMatchObject({ role: 'staff', dataScope: 'scoped', integrationManagedRole: false, integrationManagedScope: false });
expect(await t.users.detail(t.op, r.userId)).toMatchObject({ sourceUserId: 'staff-123', canMapHostUser: false, canReissueActivation: true });
const ticket = await t.users.reissueActivation(t.op, r.userId);
expect(ticket.token).not.toBe(r.token);
const activation = new ActivationService(db as never, new PasswordService());
await activation.activate(ticket.token, { phone: '+8613990000022', password: 'local-activation-test-42' });
await expect(t.users.reissueActivation(t.op, r.userId)).rejects.toMatchObject({ code: expect.any(Number) });
await expect(t.provision.openAccount(t.op, body)).rejects.toMatchObject({ msg: expect.stringContaining('已关联') });
expect(await db.user.count({ where: { tenantId: t.tenantId } })).toBe(2);
});
it('后补员工映射:普通员工、局部 owner、跨租户、本人和平台账号不能绑定;已有映射不覆盖', async () => {
const t = await ownerTools(), other = await ownerTools();
const r = await t.provision.openAccount(t.op, { displayName: '后补员工', role: 'viewer' });
if (!r.applied) throw Error('未开户');
expect(await t.users.detail(t.op, r.userId)).toMatchObject({ canMapHostUser: true, sourceUserId: null });
for (const op of [{ ...t.op, role: 'staff', permissions: permissionsOf('staff') }, { ...t.op, dataScope: 'scoped' }, other.op])
await expect(t.users.mapHostUser(op, r.userId, 'later-staff')).rejects.toBeDefined();
await expect(t.users.mapHostUser(t.op, t.op.id, 'self')).rejects.toBeDefined();
await expect(t.users.mapHostUser(t.op, r.userId, ' ')).rejects.toBeDefined();
await t.users.mapHostUser(t.op, r.userId, 'later-staff');
await expect(t.users.mapHostUser(t.op, r.userId, 'another')).rejects.toMatchObject({ msg: expect.stringContaining('不能覆盖') });
const r2 = await t.provision.openAccount(t.op, { displayName: '平台员工', role: 'viewer' });
if (!r2.applied) throw Error('未开户');
await db.userRoleGrant.create({ data: { userId: r2.userId, scope: 'platform', role: 'platform_viewer', source: 'manual' } });
await expect(t.users.mapHostUser(t.op, r2.userId, 'platform')).rejects.toBeDefined();
});
it('并发员工映射只允许一个账号占用;未接入时可以独立开户', async () => {
const t = await ownerTools();
const base = { displayName: '并发员工', role: 'staff', sourceUserId: 'concurrent-staff' };
const results = await Promise.allSettled([t.provision.openAccount(t.op, base), t.provision.openAccount(t.op, base)]);
expect(results.filter(r => r.status === 'fulfilled')).toHaveLength(1);
expect(await db.user.count({ where: { tenantId: t.tenantId } })).toBe(2);
await db.tenant.update({ where: { id: t.tenantId }, data: { integrationEnabled: false } });
await expect(t.provision.openAccount(t.op, { ...base, sourceUserId: 'new' })).rejects.toMatchObject({ msg: expect.stringContaining('尚未接入') });
expect((await t.provision.openAccount(t.op, { displayName: '独立员工', role: 'viewer' })).applied).toBe(true);
});
it('owner 建门店并维护 PAC 坐标,免登读取新映射;旧静态映射随替换及取消清除', async () => {
const t = await ownerTools();
const pacStub = { orgNodes: jest.fn().mockResolvedValue([
{ ref: 'brand', parentRef: null, kind: 'brand', name: '品牌', aliases: [] },
{ ref: 'clinic-one', parentRef: 'brand', kind: 'clinic', name: '第一门店', aliases: [] },
{ ref: 'clinic-two', parentRef: 'brand', kind: 'clinic', name: '第二门店', aliases: [] },
]) };
const org = new OrgAdminService(db as never, new ScopeService(db as never), { forgetClinics: () => 0 } as never, {} as never, {} as never, pacStub as never);
const request = { parentId: t.root.id, name: '新建门店', kind: 'clinic' };
const pre = await org.createUnit(t.op, { ...request, dryRun: true });
const clinic = await org.createUnit(t.op, { ...request, rev: pre.rev, dryRun: false });
const id = clinic.nodeId!;
await expect(org.linkPac({ ...t.op, permissions: permissionsOf('staff') }, id, { ref: 'clinic-one', dryRun: true })).rejects.toBeDefined();
await expect(org.linkPac(t.op, id, { ref: 'unknown', dryRun: true })).rejects.toMatchObject({ msg: expect.stringContaining('没有这一项') });
const link = async (ref: string | null) => {
const preview = await org.linkPac(t.op, id, { ref, dryRun: true });
return org.linkPac(t.op, id, { ref, rev: preview.rev, ack: true, dryRun: false });
};
await link('clinic-one');
const app = await db.integrationApp.findUniqueOrThrow({ where: { appId } });
const graph = await t.policy.byId(app as never, t.tenantId, t.input.partnerTenantId);
expect(graph.mappings.get('clinic-one')).toBe(id);
await db.tenant.update({ where: { id: t.tenantId }, data: { integrationPolicy: { ...graph.policy, organizationMappings: { brand: t.root.id, 'clinic-one': id } } } });
await link('clinic-two');
const replaced = await t.policy.byId(app as never, t.tenantId, t.input.partnerTenantId);
expect(replaced.mappings.has('clinic-one')).toBe(false);
expect(replaced.mappings.get('clinic-two')).toBe(id);
await link(null);
const cleared = await t.policy.byId(app as never, t.tenantId, t.input.partnerTenantId);
expect(cleared.mappings.has('clinic-two')).toBe(false);
expect(cleared.mappings.get('brand')).toBe(t.root.id);
});
it('写入中途出错事务整体回滚', async () => {
const broken = new PlatformOnboardingService(
{
tenant: db.tenant,
orgUnitRef: db.orgUnitRef,
integrationApp: db.integrationApp,
$transaction: (fn: (tx: unknown) => Promise<unknown>) =>
db.$transaction((tx) =>
fn(
new Proxy(tx, {
get(target, prop) {
if (prop === 'auditLog')
return {
create: () => {
throw Error('模拟事务末尾失败');
},
};
return Reflect.get(target, prop);
},
}),
),
),
} as unknown as PrismaService,
pac as never,
);
const input = data();
await expect(broken.create(actor, input)).rejects.toThrow('模拟事务');
expect(
await db.tenant.count({
where: { partnerCode: host, partnerTenantId: input.partnerTenantId },
}),
).toBe(0);
});
});
......@@ -9,6 +9,11 @@ import { Checkbox } from '@/components/ui/checkbox';
import { DateTimePicker } from '@/components/ui/date-time-picker';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import {
TenantOnboarding,
ActivationCard,
type Activation,
} from '@/components/platform/tenant-onboarding';
import {
Select,
SelectContent,
SelectItem,
......@@ -29,6 +34,9 @@ type User = {
phone: string | null;
role: string;
isActive: boolean;
pendingActivation: boolean;
canMapOwner: boolean;
sourceUserId: string | null;
};
type Seat = {
id: string;
......@@ -40,7 +48,11 @@ type Seat = {
reservationId: string | null;
};
type Detail = {
tenant: Tenant;
tenant: Tenant & {
partnerTenantId: string;
partnerOrgRootRef: string;
integrationAppId: string | null;
};
users: User[];
accounts: { id: string; nickname: string | null }[];
seats: Seat[];
......@@ -62,6 +74,13 @@ const verbs = { grant: '开通席位', renew: '续期', authorize: '授权扫码
type Action = keyof typeof verbs;
export default function PlatformPage() {
const router = useRouter();
const [mappingUser, setMappingUser] = useState<User | null>(null);
const [mappingId, setMappingId] = useState(''),
[mappingReason, setMappingReason] = useState('');
const [onboarding, setOnboarding] = useState(false);
const [activationUser, setActivationUser] = useState<User | null>(null);
const [activation, setActivation] = useState<Activation | null>(null);
const [activationReason, setActivationReason] = useState('');
const [me, setMe] = useState<Me | null>(null),
[tenants, setTenants] = useState<Tenant[]>([]),
[detail, setDetail] = useState<Detail | null>(null),
......@@ -144,6 +163,7 @@ export default function PlatformPage() {
}
};
const can = me?.permissions.includes('platform.hosting.manage');
const canOnboard = me?.permissions.includes('platform.tenant.manage');
return (
<main className="min-h-screen bg-slate-50 text-[12.5px] text-slate-700">
<header className="flex h-16 items-center justify-between bg-white px-6">
......@@ -158,9 +178,7 @@ export default function PlatformPage() {
{me && <WorkspaceSwitch />}
<Button
variant="ghost"
onClick={() =>
void api.post('/auth/logout', {}).then(() => router.replace('/login'))
}
onClick={() => void api.post('/auth/logout', {}).then(() => router.replace('/login'))}
>
退出
</Button>
......@@ -222,6 +240,9 @@ export default function PlatformPage() {
'hosting.revoke': '停用席位',
'platform.user.grant': '授予平台角色',
'platform.user.revoke': '收回平台角色',
'tenant.onboard': '开通客户',
'tenant.owner.activation': '重发管理员激活链接',
'tenant.owner.map': '关联宿主账号',
'auth.login': '登录',
'auth.password.change': '修改密码',
}[l.action] ?? l.action}
......@@ -240,12 +261,15 @@ export default function PlatformPage() {
<>
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold text-slate-900">客户与托管</h1>
<Input
className="max-w-64"
placeholder="搜索客户名称"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
<div className="flex items-center gap-3">
<Input
className="max-w-64"
placeholder="搜索客户名称"
value={q}
onChange={(e) => setQ(e.target.value)}
/>
{canOnboard && <Button onClick={() => setOnboarding(true)}>开通客户</Button>}
</div>
</div>
<div className="overflow-x-auto rounded-xl bg-white">
<table className="w-full text-left">
......@@ -305,6 +329,32 @@ export default function PlatformPage() {
{can ? <Button onClick={() => open('grant', null)}>开通一个席位</Button> : null}
</div>
<div className="rounded-xl bg-white p-5">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="font-semibold">数据来源与接入</h2>
</div>
<dl className="grid gap-3 sm:grid-cols-2">
<div>
<dt className="text-slate-400">PAC 宿主</dt>
<dd>{detail.tenant.partnerCode}</dd>
</div>
<div>
<dt className="text-slate-400">数据租户 ID</dt>
<dd className="break-all">{detail.tenant.partnerTenantId}</dd>
</div>
<div>
<dt className="text-slate-400">客户组织根 ID</dt>
<dd className="break-all">{detail.tenant.partnerOrgRootRef || '待配置'}</dd>
</div>
<div>
<dt className="text-slate-400">宿主应用</dt>
<dd>{detail.tenant.integrationAppId || '宿主尚未接入'}</dd>
</div>
</dl>
<p className="mt-4 text-xs text-slate-400">
客户管理员在「组织架构」维护门店和患者档案关联,在「员工授权」给同事开户并分配范围。
</p>
</div>
<div className="rounded-xl bg-white p-5">
<h2 className="mb-4 font-semibold">托管席位</h2>
<p className="mb-4 text-slate-400">
每次开通新增一个席位;解除托管不会释放已占用席位。首次扫码授权默认有效 24 小时。
......@@ -377,6 +427,35 @@ export default function PlatformPage() {
{{ owner: '管理员', staff: '员工', viewer: '只读' }[u.role] ?? u.role}
</span>
<span>{u.isActive ? '正常' : '已停用'}</span>
{u.sourceUserId && (
<span className="text-xs text-slate-500">宿主用户:{u.sourceUserId}</span>
)}
{canOnboard && u.canMapOwner && (
<Button
variant="ghost"
onClick={() => {
setMappingUser(u);
setMappingId('');
setMappingReason('');
setError('');
}}
>
关联宿主账号
</Button>
)}
{canOnboard && u.pendingActivation && (
<Button
variant="outline"
onClick={() => {
setActivationUser(u);
setActivation(null);
setActivationReason('');
setError('');
}}
>
生成管理员激活链接
</Button>
)}
</div>
))}
</div>
......@@ -385,6 +464,132 @@ export default function PlatformPage() {
</section>
</div>
<Dialog
open={!!mappingUser}
onOpenChange={(v) => {
if (!v && !busy) setMappingUser(null);
}}
>
<DialogContent title="关联宿主账号" variant="form">
<form
className="mt-4 space-y-4"
onSubmit={async (e) => {
e.preventDefault();
if (!detail || !mappingUser) return;
setBusy(true);
setError('');
try {
await api.post(
`/platform/tenants/${detail.tenant.id}/owners/${mappingUser.id}/mapping`,
{ sourceUserId: mappingId.trim(), reason: mappingReason.trim() },
);
await reload(detail.tenant.id);
setMappingUser(null);
} catch (e) {
setError(e instanceof Error ? e.message : '关联失败');
} finally {
setBusy(false);
}
}}
>
<p>{mappingUser?.displayName}</p>
<p className="text-sm text-slate-500">
沿用客户所属宿主的接入规则。请确认源系统用户与该管理员是同一人,映射后内嵌与独立登录使用同一账号。
</p>
<label className="block space-y-2">
<span>宿主用户 ID</span>
<Input
required
pattern="\S+"
maxLength={128}
value={mappingId}
onChange={(e) => setMappingId(e.target.value)}
/>
</label>
<label className="block space-y-2">
<span>关联说明</span>
<Input
required
maxLength={500}
value={mappingReason}
onChange={(e) => setMappingReason(e.target.value)}
/>
</label>
{error && (
<p role="alert" className="text-rose-600">
{error}
</p>
)}
<Button type="submit" disabled={busy}>
{busy ? '处理中…' : '确认关联'}
</Button>
</form>
</DialogContent>
</Dialog>
{onboarding && (
<TenantOnboarding
onClose={() => setOnboarding(false)}
onCreated={(id) => {
void reload(id).catch((e) => setError(e.message));
}}
/>
)}
<Dialog
open={!!activationUser}
onOpenChange={(v) => {
if (!v && !busy) {
setActivationUser(null);
setActivation(null);
}
}}
>
<DialogContent title="管理员激活" variant="form">
<div className="mt-4 space-y-4">
<p>{activationUser?.displayName}</p>
{activation ? (
<ActivationCard activation={activation} />
) : (
<>
<p className="text-slate-500">
生成后旧链接立即失效,新链接有效 30
分钟。已设置密码或通过宿主登录的账号不能在此重置。
</p>
<Input
aria-label="重新生成的原因"
placeholder="重新生成的原因"
value={activationReason}
maxLength={500}
onChange={(e) => setActivationReason(e.target.value)}
/>
<Button
loading={busy}
disabled={!activationReason.trim()}
onClick={() => {
if (!detail || !activationUser) return;
setBusy(true);
setError('');
void api
.post<Activation>(
`/platform/tenants/${detail.tenant.id}/owners/${activationUser.id}/activation`,
{ reason: activationReason.trim() },
)
.then(setActivation)
.catch((e) => setError(e.message))
.finally(() => setBusy(false));
}}
>
生成新链接
</Button>
</>
)}
{error && (
<p role="alert" className="text-rose-600">
{error}
</p>
)}
</div>
</DialogContent>
</Dialog>
<Dialog
open={!!action}
onOpenChange={(v) => {
if (!v && !busy) setAction(null);
......
......@@ -203,10 +203,11 @@ export function OrgNodeDetail({
) : null}
</Section>
<Section title="患者档案" note="查患者资料、自动生成预约确认,都按这里关联的去找">
<Section title="患者档案" note="关联宿主中的对应组织,患者资料和免登门店识别沿用此映射">
<Field label="现在">
<span className={`text-[12px] ${node.pacRef || node.patientReadable ? 'text-slate-700' : 'text-amber-700'}`}>{pacNow}</span>
</Field>
{node.pacRef ? <Field label="宿主组织 ID"><span className="text-[12px] break-all">{node.pacRef}</span></Field> : null}
{edit ? (
!node.isActive ? (
<Muted>已停用的组织不能改患者档案,请先恢复它</Muted>
......
......@@ -11,6 +11,7 @@ import {
type RoleChangeResult,
type ScopeChangeResult,
} from '@/lib/api';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { notify } from '@/components/ui/sonner';
......@@ -67,6 +68,9 @@ export function PersonDetail({
const [ticket, setTicket] = useState<ActivationTicket | null>(null);
const [copied, setCopied] = useState(false);
const [savingProfile, setSavingProfile] = useState(false);
const [hostUserId, setHostUserId] = useState('');
const hostFlow = useTwoStep<{sourceUserId: string}>('关联失败');
useEffect(() => { setHostUserId(''); hostFlow.reset(); }, [detail.id, hostFlow.reset]);
// ⚠️ 预演之后又改了勾选 ⇒ 那份后果已经不对了
const { reset: resetScope } = scopeFlow;
......@@ -178,9 +182,23 @@ export function PersonDetail({
/>
) : null}
<Field label="宿主用户 ID">
{detail.sourceUserId ? <span className="text-[12px]">{detail.sourceUserId}</span> : detail.canMapHostUser ? (
<div className="space-y-2">
<Input aria-label="宿主用户 ID" value={hostUserId} maxLength={128} onChange={e => {setHostUserId(e.target.value); hostFlow.reset();}} />
<Muted>填写此员工在宿主里的 userId,关联后两个入口使用同一账号。</Muted>
{!hostFlow.plan ? <Button size="sm" disabled={!hostUserId.trim() || hostFlow.busy} onClick={() => void hostFlow.preview(async () => ({sourceUserId: hostUserId.trim()}))}>关联宿主账号</Button> : null}
<ConfirmPanel flow={hostFlow} lines={[`将宿主用户 ${hostFlow.plan?.sourceUserId ?? ''} 关联到「${name}」。`, '请确认是同一人;已有映射不能覆盖。']} button="确认关联" onConfirm={async () => {
const result = await hostFlow.commit(p => api.mapHostUser(detail.id, p.sourceUserId));
if (result) { notify.ok('已关联宿主账号'); hostFlow.reset(); onChanged(); }
}} />
</div>
) : <Muted>未关联</Muted>}
</Field>
<Field label="激活">
<div className="flex items-center gap-2 text-[12px]">
{detail.activated ? (
{detail.activated && !detail.canReissueActivation ? (
'已激活'
) : (
<>
......
'use client';
import { useState } from 'react';
import { api } from '@/lib/api';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogContent } from '@/components/ui/dialog';
type Preview = { dataStatus: string; warnings: string[] };
export type Activation = { token: string; expiresAt: string };
type Created = {
tenant: { id: string; name: string };
owner: { id: string; displayName: string };
activation: Activation | null;
warnings: string[];
};
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<label className="block space-y-1.5">
<span className="text-xs font-medium text-slate-600">{label}</span>
{children}
{hint && <span className="block text-xs text-slate-400">{hint}</span>}
</label>
);
}
export function ActivationCard({ activation }: { activation: Activation }) {
const [copied, setCopied] = useState(false),
[error, setError] = useState('');
const url =
typeof window === 'undefined'
? ''
: `${window.location.origin}/activate?t=${encodeURIComponent(activation.token)}`;
return (
<div className="space-y-3 rounded-xl bg-blue-50 p-4">
<p className="font-medium">转交给管理员本人激活</p>
<p className="text-xs text-slate-500">
本人打开链接后填写手机号并设置密码。有效至{' '}
{new Date(activation.expiresAt).toLocaleString('zh-CN')},使用一次后失效。
</p>
<Input aria-label="管理员激活链接" readOnly value={url} onFocus={(e) => e.target.select()} />
<Button
variant="outline"
onClick={() => {
void navigator.clipboard
.writeText(url)
.then(() => setCopied(true))
.catch(() => setError('未能复制,请选中上面的链接手动复制'));
}}
>
{copied ? '已复制' : '复制激活链接'}
</Button>
{error && (
<p role="alert" className="text-rose-600">
{error}
</p>
)}
<p className="text-xs text-slate-500">关闭后不再显示此链接;未激活时可在客户详情重新生成。</p>
</div>
);
}
export function TenantOnboarding({
onClose,
onCreated,
}: {
onClose: () => void;
onCreated: (id: string) => void;
}) {
const [step, setStep] = useState(0),
[busy, setBusy] = useState(false),
[error, setError] = useState('');
const [name, setName] = useState('');
const [host, setHost] = useState('friday'),
[space, setSpace] = useState('friday-market'),
[root, setRoot] = useState('');
const [sourceUserId, setSourceUserId] = useState('');
const [owner, setOwner] = useState(''),
[reason, setReason] = useState('');
const [preview, setPreview] = useState<Preview | null>(null),
[confirmed, setConfirmed] = useState(false),
[created, setCreated] = useState<Created | null>(null);
const payload = () => ({
name: name.trim(),
partnerCode: host.trim(),
partnerTenantId: space.trim(),
partnerOrgRootRef: root.trim(),
ownerName: owner.trim(),
...(sourceUserId.trim() ? { sourceUserId: sourceUserId.trim() } : {}),
reason: reason.trim(),
});
const next = async () => {
setError('');
if (step === 0) {
setStep(1);
return;
}
setBusy(true);
try {
if (step === 1) {
setPreview(await api.post<Preview>('/platform/onboarding/preview', payload()));
setConfirmed(false);
setStep(2);
} else {
const result = await api.post<Created>('/platform/tenants', payload());
setCreated(result);
setStep(3);
onCreated(result.tenant.id);
}
} catch (e) {
setError(e instanceof Error ? e.message : '操作未完成,请核对后重试');
} finally {
setBusy(false);
}
};
return (
<Dialog
open
onOpenChange={(v) => {
if (!v && !busy) onClose();
}}
>
<DialogContent
title={created ? '客户已开通' : '开通客户'}
variant="form"
className="w-[min(94vw,720px)]"
>
<form
className="mt-5 space-y-5"
onSubmit={(e) => {
e.preventDefault();
void next();
}}
>
{!created && (
<ol className="flex gap-3 text-xs" aria-label="开户步骤">
{['客户与数据来源', '首位管理员', '核对并开通'].map((label, i) => (
<li
key={label}
className={`rounded-full px-3 py-2 ${step === i ? 'bg-blue-50 font-medium text-brand-600' : 'bg-slate-50 text-slate-400'}`}
aria-current={step === i ? 'step' : undefined}
>
{i + 1}. {label}
</li>
))}
</ol>
)}
{step === 0 && (
<>
<Field label="客户名称">
<Input
required
minLength={2}
maxLength={64}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="品牌或集团名称"
/>
</Field>
<p className="rounded-xl bg-blue-50 p-4 text-slate-600">
管理员激活后可独立登录。宿主内嵌沿用已接入宿主的规则,两种入口可同时使用。
</p>
<Field
label="PAC 宿主"
hint="患者数据的来源标识,如 friday 或 jvs-dw;无需先配置免登应用。"
>
<Input
required
pattern="[a-z0-9][a-z0-9_-]{0,63}"
value={host}
onChange={(e) => setHost(e.target.value)}
/>
</Field>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="PAC 数据租户 ID" hint="Friday SaaS 使用 friday-market;不是品牌 ID。">
<Input
required
maxLength={128}
pattern="\S+"
value={space}
onChange={(e) => setSpace(e.target.value)}
/>
</Field>
<Field
label="客户组织根 ID"
hint="该品牌在宿主/PAC 中的根编号;Friday SaaS 填原生品牌 tenantId。"
>
<Input
required
maxLength={128}
pattern="\S+"
value={root}
onChange={(e) => setRoot(e.target.value)}
/>
</Field>
</div>
</>
)}
{step === 1 && (
<>
<Field label="管理员姓名">
<Input
required
minLength={2}
maxLength={32}
value={owner}
onChange={(e) => setOwner(e.target.value)}
/>
</Field>
<p className="rounded-xl bg-blue-50 p-4 text-slate-600">
开户后生成 30
分钟有效的激活链接。由管理员本人填写手机号、设置密码,即可独立登录搭建组织。
</p>
<Field
label="宿主用户 ID(可选)"
hint="填此人在宿主中的 userId,用于关联同一个账号;不是手机号。暂不清楚可在客户员工处补充。"
>
<Input
maxLength={128}
pattern="\S+"
value={sourceUserId}
onChange={(e) => setSourceUserId(e.target.value)}
/>
</Field>
<div className="rounded-xl bg-slate-50 p-4">
<p className="font-medium">客户管理员 · 全客户范围</p>
<p className="mt-1 text-slate-500">
可管理本客户的组织树、门店数据关联、员工账号及授权。平台管理和托管席位由平台人员处理。
</p>
</div>
<Field label="开户说明">
<Input
required
maxLength={500}
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="记录客户及管理员信息的确认依据"
/>
</Field>
</>
)}
{step === 2 && preview && (
<>
<dl className="grid grid-cols-[8rem_1fr] gap-3 rounded-xl bg-slate-50 p-4">
<dt>客户</dt>
<dd>{name}</dd>
<dt>数据来源</dt>
<dd className="break-all">
{host} / {space}
</dd>
<dt>组织根</dt>
<dd className="break-all">{root}</dd>
<dt>组织搭建</dt>
<dd>由管理员登录后添加</dd>
<dt>首位管理员</dt>
<dd>{owner} · 客户管理员</dd>
<dt>宿主账号映射</dt>
<dd>{sourceUserId || '暂未填写'}</dd>
<dt>首次登录</dt>
<dd>本人激活后登录</dd>
<dt>组织数据核对</dt>
<dd>
{preview.dataStatus === 'verified' ? '已找到客户范围内的组织编号' : '尚未完成'}
</dd>
</dl>
{preview.warnings.map((w) => (
<p key={w} className="rounded-lg bg-amber-50 p-3 text-amber-800">
{w}
</p>
))}
<p className="text-xs text-slate-500">
将同时创建客户、顶层节点和首位管理员。托管席位与自动发送后续单独开通。
</p>
<label className="flex items-start gap-2">
<Checkbox checked={confirmed} onCheckedChange={(v) => setConfirmed(v === true)} />
<span>已核对客户数据范围和管理员信息,并了解上面的数据接入状态。</span>
</label>
</>
)}
{created && (
<>
<p className="text-base font-medium text-emerald-700">{created.tenant.name}已开通</p>
<p>{created.owner.displayName}已设为客户管理员,可管理本客户的组织树和员工。</p>
{created.activation ? (
<ActivationCard activation={created.activation} />
) : (
<p className="rounded-xl bg-blue-50 p-4">请在客户详情为首位管理员生成激活链接。</p>
)}
{created.warnings.map((w) => (
<p key={w} className="rounded-lg bg-amber-50 p-3 text-amber-800">
{w}
</p>
))}
<p className="text-slate-500">
接下来:管理员搭建组织树、关联门店数据并给同事开户。宿主内嵌按既有接入规则识别账号和门店,托管席位另行开通。
</p>
</>
)}
{error && (
<p role="alert" className="rounded-lg bg-rose-50 p-3 text-rose-700">
{error}
</p>
)}
<div className="flex justify-end gap-2 border-t border-slate-100 pt-4">
{created ? (
<Button onClick={onClose}>进入客户详情</Button>
) : (
<>
<Button
variant="outline"
disabled={busy}
onClick={() => {
if (step) {
setStep(step - 1);
setError('');
} else onClose();
}}
>
{step ? '上一步' : '取消'}
</Button>
<Button type="submit" disabled={busy || (step === 2 && !confirmed)}>
{busy
? '处理中…'
: step === 0
? '下一步'
: step === 1
? '核对开户信息'
: '确认开通'}
</Button>
</>
)}
</div>
</form>
</DialogContent>
</Dialog>
);
}
......@@ -47,6 +47,7 @@ export function ProvisionDrawer({
}) {
const [role, setRole] = useState<'staff' | 'viewer'>(contact ? 'staff' : 'viewer');
const [name, setName] = useState('');
const [sourceUserId, setSourceUserId] = useState('');
const [anchors, setAnchors] = useState<AnchorInput[]>([]);
const [nodes, setNodes] = useState<GrantableNode[] | null>(null);
/** 开完之后拿到的一次性链接。⚠️ **只出现这一次** —— 关掉就再也看不到了 */
......@@ -69,12 +70,13 @@ export function ProvisionDrawer({
// ⚠️ 预演之后又改了输入 ⇒ 那份后果已经不对了,收起来重新预演
useEffect(() => {
reset();
}, [role, name, anchors, reset]);
}, [role, name, sourceUserId, anchors, reset]);
const body = {
contactId: contact?.id,
displayName: contact ? undefined : name.trim(),
role,
sourceUserId: sourceUserId.trim() || undefined,
anchors,
};
......@@ -114,7 +116,7 @@ export function ProvisionDrawer({
必须把这句话说在人**还没关掉**的时候;也要说清它是**凭据**。
*/}
<div className="rounded-lg bg-amber-50 px-3 py-2 text-[11.5px] leading-[1.7] text-amber-900">
这条链接<b>只显示这一次</b>,关闭后就看不到了(需要的话可以重新开账号)。
这条链接<b>只显示这一次</b>,关闭后就看不到了(可在员工授权页重发激活链接)。
<br />
这是一次性的开户链接,只转给<b>本人</b>。<b>30 分钟</b>后失效,用过一次也会失效。
</div>
......@@ -176,6 +178,12 @@ export function ProvisionDrawer({
) : null}
<div>
<label htmlFor="prov-host-user" className="mb-1.5 block text-[11px] font-semibold text-slate-500">宿主用户 ID(可选)</label>
<Input id="prov-host-user" value={sourceUserId} maxLength={128} onChange={e => setSourceUserId(e.target.value)} />
<p className="mt-1 text-[11px] text-slate-500">填写员工在宿主里的 userId,使独立登录和免登使用同一个账号。也可开户后补充。</p>
</div>
<div>
<div className="mb-1.5 text-[11px] font-semibold text-slate-500">角色</div>
<div className="flex gap-2">
{/* ⛔ 这里开不出管理员 —— 那等于把提权通道复制一份出去 */}
......@@ -222,7 +230,7 @@ export function ProvisionDrawer({
</div>
{copy ? (
<ConfirmPanel flow={flow} lines={copy.lines} button={copy.button} onConfirm={() => void confirm()} />
<ConfirmPanel flow={flow} lines={sourceUserId.trim() ? [...copy.lines, `关联宿主用户:${sourceUserId.trim()}`] : copy.lines} button={copy.button} onConfirm={() => void confirm()} />
) : (
<div className="flex gap-2">
{/* ⚠️ 姓名没填就禁用 —— 后端也判(2–32 字),⛔ 前端这道只是别让人白跑一趟 */}
......
......@@ -174,6 +174,7 @@ export const api = {
setUserScopes: (id: string, body: { anchors: AnchorInput[]; rev?: number; dryRun: boolean }) =>
api.put<ScopeChangeResult>(`/users/${encodeURIComponent(id)}/scopes`, body),
/** ⚠️ 缺键 = 不改,`null` = 清空 */
mapHostUser: (id: string, sourceUserId: string) => api.post<{sourceUserId: string}>(`/users/${encodeURIComponent(id)}/host-identity`, {sourceUserId}),
setUserProfile: (id: string, body: { orgUnitId?: string | null; jobTitle?: string | null }) =>
api.patch<ProfileSetResult>(`/users/${encodeURIComponent(id)}/profile`, body),
setUserRole: (id: string, body: { role: 'staff' | 'viewer'; dryRun: boolean }) =>
......@@ -185,6 +186,7 @@ export const api = {
provision: (body: {
contactId?: string;
displayName?: string;
sourceUserId?: string;
role: 'staff' | 'viewer';
anchors: AnchorInput[];
dryRun: boolean;
......@@ -1700,6 +1702,9 @@ export interface AdminCan {
}
export interface AdminUserRow {
sourceUserId?: string | null;
canMapHostUser?: boolean;
canReissueActivation?: boolean;
id: string;
displayName: string;
role: string;
......
......@@ -7,13 +7,51 @@
平台管理首期提供:
- 客户列表、员工与企微号查询。
- 客户开户:数据来源、组织根、首位客户管理员;激活后由管理员搭建组织。
- 托管席位开通、续期、指定期限、永久、停用。
- 给员工授权首次扫码;默认 24 小时。
- 平台操作记录:操作人、目标客户、动作、原因、结果;最近 100 条。
开通一次新增一个席位。页面与 `ai:hosting` CLI 共用 `HostingAdminService`,不会因入口不同改变规则。解除托管、员工转交仍在原工作台操作。
客户开户、宿主接入配置和平台人员授权暂保留 CLI;没有开放平台用户自助注册或客户管理员提权入口。
客户开户支持平台页面。宿主应用注册/密钥轮换和平台人员授权保留 CLI;没有开放平台用户自助注册或客户管理员提权入口。
## 客户开户
平台管理员在「客户与托管」点击「开通客户」,按三步完成:客户与数据来源 → 首位管理员 → 核对并开通。平台只读人员和客户 owner 不能调用开户接口。
| 信息 | 填写内容 |
| --- | --- |
| 客户名称 | 品牌或集团的名称,同时用作组织树根名称 |
| 首次进入 | 统一使用一次性激活链接,管理员本人设置手机号和密码 |
| 宿主 | 填写 PAC 宿主,如 `friday` / `jvs-dw` |
| PAC 数据租户 ID | 数据所在空间;Friday SaaS 是 `friday-market`,不是原生品牌 ID |
| 客户组织根 ID | 本客户在数据空间中的根;Friday SaaS 填原生品牌 `tenantId` |
| 组织树 | 开户只建顶层节点;管理员登录后自行添加品牌、区域、门店和关联编号 |
| 首位管理员 | 姓名;可选填写该人员的宿主 userId,关联同一个账号;不填写门店 |
| 开户说明 | 记录信息确认依据,进入平台操作记录 |
预览只读取 PAC 组织字典,不读取患者。PAC 不可达或字典为空会明确提示未完成数据核验,允许先开户,不能将此视为患者数据验收通过。
提交在同一事务中创建客户、品牌根、首位 `owner`、激活票和正式租户角色授权。首位 owner 为全客户范围;按宿主自动关联唯一有效接入应用;填写源用户 ID 时,同时建立身份关系。不创建门店。重复开户、跨客户重用编号会被拒绝;并发提交不会重复开户;中途失败整体回滚。不创建托管席位,不设置 `aiAutoSend`,不发消息。
首次登录不由平台设置密码:开户结果显示一次性激活链接,30 分钟有效,由管理员本人填写手机号和密码。尚未激活且不具备平台角色的 owner(已映射宿主时须已有开户激活票) 可在客户详情重新生成链接,旧链接立即作废;已激活人员不能借此重置密码。激活票只存摘要,不进入操作日志,返回凭据的接口禁止缓存。
### 同一账号的两个入口
独立登录与宿主内嵌可以同时使用,无需客户单独选择或配置免登。新开户客户按 `partnerCode` 自动关联唯一有效的宿主应用,继承宿主统一的角色、模块和能力上限;已有客户的显式接入限制保持不变。
首位 owner 的源账号映射可在开户时填写,也可在客户详情的「客户员工 → 关联宿主账号」补充。填写源系统 userId,由平台人员核对同一人和客户归属;以应用、客户、源用户三者隔离映射,不能按姓名或手机号猜测关联,不能覆盖已有映射或合并账号。不会另建一个 owner,也不自动提权宿主应用。
没有接入应用时可以先独立开户,注册宿主后补映射;同一宿主有多个有效应用时需接入人员明确归属,不能自动猜测。宿主没开放 admin 时提示管理员权限尚未开放,但不阻止开户保存身份关系。内嵌进入仍校验应用权限、客户根和门店映射;未关联门店不会自动落到组织根。
### 开户后由谁维护
- **客户 owner**:全客户授权下,在「组织架构」建/改组织,在「患者档案」选择 PAC 编号关联门店;在「员工授权」开户、授予范围、调整岗位。只有 owner 角色但范围不完整,并不等于可以改整棵树。宿主会话还受本次携带范围和应用能力限制。
- **平台管理员**:开户时确定宿主、数据空间、客户根;客户详情可查看这些信息。已有客户的数据根不提供随意改绑,变更需要另行核对历史数据与身份。平台人员仍单独负责托管席位。
- **接入配置人员**:注册宿主应用及密钥;不同源系统的组织编号不一致时,继续使用受控接入配置,不在本页面猜测编号。现有门店关联选择器依赖 PAC 组织字典,字典未就绪时需先完成数据接入。
本次可视化开户无需新增数据库迁移或环境变量;既有 CLI 可继续使用。
## 开通平台账号
......
# 平台客户开户本地验证
日期:2026-10-12。分支:`codex/friday-ai-production-fixes`。
## 范围
平台三步开户:客户与数据来源、首位管理员、核对并开通。支持宿主免登和独立激活。首位 owner 为全客户范围;创建客户不自动开通托管席位或发送消息。本次无新增迁移和环境变量。
## 验证结果
- 后端、前端 TypeScript 检查通过,`git diff --check` 通过。
- 五组回归共 58 项通过:`platform-onboarding.integration`、`platform-rbac.integration`、`platform-admin`、`integration-boundary`、`permissions`。
- 开户数据库测试使用本机 PostgreSQL,验证事务回滚、并发防重、跨客户坐标冲突、角色范围、宿主身份与组织映射、激活和重发限制。临时数据按测试专属标识清理。
- 浏览器实测三步表单、PAC 字典为空时的提示与确认、提交成功、客户详情和数据来源展示。界面使用真实前端及开户服务、本地数据库;登录与 PAC 响应由隔离的本地验收入口模拟,不能视为线上免登或 PAC 联调验收。
- 临时验收页面及服务已关闭,临时客户、管理员和应用已清理。
## 发布边界
本次 FridayAI 代码保留本地未提交,未合入 main/test,未部署测试或生产。
Friday SaaS 后端的品牌管理员组织根范围调整另已提交:功能分支 `5080b68`,合并并推送至 `test@1f80734`、`master@408f19b`;未在本次执行部署。
## 流程调整复验
- 调整后五组回归共 60 项通过,前后端类型检查通过;共享工作树依赖的 Jest 运行使用 `NODE_PATH=apps/ai-service/node_modules`(绝对路径)。
- 开户移除门店配置和登录方式选择,组织树交给 owner;指定的顶层节点说明文案已从页面删除。
- 首位管理员可选填源系统 userId,也可在员工处补映射;独立登录与宿主内嵌使用同一身份。
- 新客户沿用所属宿主统一权限,旧客户显式策略不变。未开放 admin 的宿主不会因开户而自动提权。
- 本地真实数据库验证先映射再激活、映射后重发激活票、后补映射、跨客户与重复映射拒绝、宿主能力继承、无应用独立开户及回滚。
- 本轮浏览器连接真实本地 AI 服务,验证无需门店/应用选择即可进入管理员步骤,填写源账号映射后可进入核对页;宿主缺少 admin 权限和 PAC 暂无法核验均明确提示。浏览器未提交新客户,未修改测试或生产。
- 本地 3113 前端及 3104 AI 服务保留供用户测试;关闭的仅为首版验收临时入口。
## owner 员工与门店映射复验
- 新增员工(直接建号、通讯录开户)可选填宿主用户 ID;员工详情支持补关联。只允许本客户全范围管理员操作,不能覆盖已有身份、关联本人/平台账号或跨客户账号;不改变员工角色、组织授权及托管状态。
- 映射后仍可使用原独立激活流程;未设置密码且曾发票的账号可重发激活链接,设置密码后拒绝重发。
- 门店沿用组织架构中的患者档案关联入口,并显示宿主组织 ID。候选限定 PAC 返回的本客户组织;替换/取消时清除旧接入策略中的该节点静态映射,免登统一读取最新关联。客户根路由标识保留。
- 六组回归 85 项通过,包含真实本地数据库开户、身份冲突、并发、越权与门店关联替换/取消;前后端类型检查通过。
- 本地后端 watch 只编译而未重新加载旧进程,已单独重启 3104 使本次代码生效。未操作测试或生产。
- 浏览器以临时 owner 登录真实 3113/3104,验证新增员工字段、已有员工补关联确认页和门店坐标展示;没有通过浏览器提交身份授权。门店真实候选依赖本地 PAC 授权和组织字典,当前本地 friday 读取被 PAC 拒绝,不能将本次隔离数据库测试当作真实数据联调通过。
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment