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 @@ ...@@ -33,6 +33,7 @@
*/ */
export const PERMISSIONS = { export const PERMISSIONS = {
PLATFORM_VIEW: 'platform.view', PLATFORM_VIEW: 'platform.view',
PLATFORM_TENANT_MANAGE: 'platform.tenant.manage',
PLATFORM_HOSTING_MANAGE: 'platform.hosting.manage', PLATFORM_HOSTING_MANAGE: 'platform.hosting.manage',
PLATFORM_AUDIT_VIEW: 'platform.audit.view', PLATFORM_AUDIT_VIEW: 'platform.audit.view',
APPOINTMENT_CREATE: 'appointment.create', APPOINTMENT_CREATE: 'appointment.create',
...@@ -149,7 +150,7 @@ export const ADMIN_PERMISSIONS: readonly Permission[] = [ ...@@ -149,7 +150,7 @@ export const ADMIN_PERMISSIONS: readonly Permission[] = [
* 未知角色的正确处理是**什么都不给**,见 `permissionsOf`。 * 未知角色的正确处理是**什么都不给**,见 `permissionsOf`。
*/ */
const ROLE_PERMISSIONS: Record<string, readonly Permission[]> = { 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], platform_viewer: [PERMISSIONS.PLATFORM_VIEW, PERMISSIONS.PLATFORM_AUDIT_VIEW],
owner: [ owner: [
PERMISSIONS.APPOINTMENT_CREATE, PERMISSIONS.APPOINTMENT_CREATE,
......
...@@ -86,7 +86,16 @@ export class IntegrationPolicyService { ...@@ -86,7 +86,16 @@ export class IntegrationPolicyService {
throw new BizError(12804, '集团未开通或已停用'); throw new BizError(12804, '集团未开通或已停用');
const parsed = tenantPolicySchema.safeParse(row.integrationPolicy); const parsed = tenantPolicySchema.safeParse(row.integrationPolicy);
if (!parsed.success) throw new BizError(12804, '集团接入规则未就绪'); 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({ const nodes = await db.orgUnit.findMany({
where: { tenantId: id }, where: { tenantId: id },
select: { id: true, parentId: true, isActive: true, kind: true }, select: { id: true, parentId: true, isActive: true, kind: true },
...@@ -260,9 +269,13 @@ export class IntegrationPolicyService { ...@@ -260,9 +269,13 @@ export class IntegrationPolicyService {
} }
async context(session: AuthSession, db: IntegrationDb = this.prisma): Promise<HostScope | null> { async context(session: AuthSession, db: IntegrationDb = this.prisma): Promise<HostScope | null> {
if (session.source !== 'host') return null; if (session.source !== 'host') return null;
if (!session.tenantId || session.scope === 'platform') throw new BizError(12810, '宿主会话范围无效'); if (!session.tenantId || session.scope === 'platform')
const roleGrant = await db.userRoleGrant.findUnique({where:{userId_scope:{userId:session.userId,scope:'tenant'}}}); throw new BizError(12810, '宿主会话范围无效');
if (!roleGrant || roleGrant.tenantId !== session.tenantId) 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; const ctx = session.integrationContext as unknown as HostSessionContext;
if (ctx?.version !== 2 || !ctx.appId || !ctx.launchId) if (ctx?.version !== 2 || !ctx.appId || !ctx.launchId)
throw new BizError(12810, '旧宿主会话已结束,请重新进入'); throw new BizError(12810, '旧宿主会话已结束,请重新进入');
......
...@@ -17,7 +17,11 @@ export const BASIC_WRITE = [ ...@@ -17,7 +17,11 @@ export const BASIC_WRITE = [
'channel.bind', 'channel.bind',
'appointment.task.process', 'appointment.task.process',
] as const; ] 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[]> = { const BASE_BY_ROLE: Record<string, readonly string[]> = {
viewer: BASIC_READ, viewer: BASIC_READ,
staff: [...BASIC_READ, ...BASIC_WRITE], staff: [...BASIC_READ, ...BASIC_WRITE],
...@@ -112,6 +116,7 @@ export const appSchema = z ...@@ -112,6 +116,7 @@ export const appSchema = z
.strict(); .strict();
export const tenantPolicySchema = z export const tenantPolicySchema = z
.object({ .object({
inheritAppPolicy: z.boolean().optional(),
version: z.literal(2), version: z.literal(2),
rootOrgId: z.string().uuid(), rootOrgId: z.string().uuid(),
modules: z.array(moduleName).min(1).max(2).default(['workbench', 'appointment-confirm']), 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 { 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 { z } from 'zod';
import { ZodValidationException } from 'nestjs-zod'; import { ZodValidationException } from 'nestjs-zod';
function input<T extends z.ZodType>(schema: T, value: unknown): z.output<T> { 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'; ...@@ -16,6 +16,8 @@ import { LoginThrottleGuard, ThrottleCost } from '../auth/login-throttle.guard';
import { PasswordLoginService } from '../auth/password-login.service'; import { PasswordLoginService } from '../auth/password-login.service';
import { SessionService } from '../auth/session.service'; import { SessionService } from '../auth/session.service';
import { PlatformService } from './platform.service'; import { PlatformService } from './platform.service';
import { PlatformOnboardingService } from './platform-onboarding.service';
import { onboardingSchema, ownerMappingSchema } from './platform-onboarding.schema';
@Controller('platform') @Controller('platform')
@AuthScope('platform') @AuthScope('platform')
...@@ -24,6 +26,7 @@ export class PlatformController { ...@@ -24,6 +26,7 @@ export class PlatformController {
private readonly platform: PlatformService, private readonly platform: PlatformService,
private readonly passwords: PasswordLoginService, private readonly passwords: PasswordLoginService,
private readonly sessions: SessionService, private readonly sessions: SessionService,
private readonly onboarding: PlatformOnboardingService,
) {} ) {}
@Get('me') @Get('me')
@AllowTempPassword() @AllowTempPassword()
...@@ -54,6 +57,58 @@ export class PlatformController { ...@@ -54,6 +57,58 @@ export class PlatformController {
tenants() { tenants() {
return this.platform.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') @Get('tenants/:id')
@RequirePermission(PERMISSIONS.PLATFORM_VIEW) @RequirePermission(PERMISSIONS.PLATFORM_VIEW)
detail(@Param('id') id: string) { detail(@Param('id') id: string) {
......
...@@ -3,9 +3,17 @@ import { Module } from '@nestjs/common'; ...@@ -3,9 +3,17 @@ import { Module } from '@nestjs/common';
import { PlatformController } from './platform.controller'; import { PlatformController } from './platform.controller';
import { PlatformService } from './platform.service'; import { PlatformService } from './platform.service';
import { HostingAdminService } from '../hosting/hosting-admin.service'; import { HostingAdminService } from '../hosting/hosting-admin.service';
import { PacModule } from '../pac/pac.module';
import { PlatformOnboardingService } from './platform-onboarding.service';
@Module({ @Module({
imports: [PacModule],
controllers: [PlatformController], controllers: [PlatformController],
providers: [PlatformAdminService, PlatformService, HostingAdminService], providers: [
PlatformAdminService,
PlatformService,
HostingAdminService,
PlatformOnboardingService,
],
exports: [HostingAdminService], exports: [HostingAdminService],
}) })
export class PlatformModule {} export class PlatformModule {}
...@@ -8,6 +8,7 @@ import { Prisma } from '../../../generated/prisma'; ...@@ -8,6 +8,7 @@ import { Prisma } from '../../../generated/prisma';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { HostingAdminService } from '../hosting/hosting-admin.service'; import { HostingAdminService } from '../hosting/hosting-admin.service';
import type { PlatformUser } from '../../common/guards/jwt-auth.guard'; import type { PlatformUser } from '../../common/guards/jwt-auth.guard';
import { identityProvider } from '../integration/integration.schema';
import { maskPhone } from '../../common/mask'; import { maskPhone } from '../../common/mask';
@Injectable() @Injectable()
...@@ -64,7 +65,18 @@ export class PlatformService { ...@@ -64,7 +65,18 @@ export class PlatformService {
const users = await this.db.user.findMany({ const users = await this.db.user.findMany({
where: { tenantId }, where: { tenantId },
orderBy: { createdAt: 'desc' }, 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({ const accounts = await this.db.wecomAccount.findMany({
where: { tenantId }, where: { tenantId },
...@@ -76,7 +88,27 @@ export class PlatformService { ...@@ -76,7 +88,27 @@ export class PlatformService {
}); });
return { return {
tenant, 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, accounts,
seats, seats,
}; };
......
...@@ -20,6 +20,9 @@ export interface AdminCan { ...@@ -20,6 +20,9 @@ export interface AdminCan {
} }
export interface AdminUserRow { export interface AdminUserRow {
sourceUserId?: string | null;
canMapHostUser?: boolean;
canReissueActivation?: boolean;
id: string; id: string;
displayName: string; displayName: string;
role: string; role: string;
......
...@@ -211,6 +211,7 @@ export class ContactController { ...@@ -211,6 +211,7 @@ export class ContactController {
anchors: readAnchors(b.anchors), anchors: readAnchors(b.anchors),
dryRun: readBool(b.dryRun, 'dryRun'), dryRun: readBool(b.dryRun, 'dryRun'),
rev: b.rev, 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 { Injectable, Logger } from '@nestjs/common';
import { Prisma } from '../../../generated/prisma'; import { Prisma } from '../../../generated/prisma';
import { AiApiCode } from '../../common/errors/api-codes'; import { AiApiCode } from '../../common/errors/api-codes';
...@@ -314,6 +315,14 @@ export class OrgAdminService { ...@@ -314,6 +315,14 @@ export class OrgAdminService {
data: { tenantId: op.tenantId, orgUnitId: id, hostCode: snap.hostCode, externalId: to }, 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); const rev = await bumpOrgRev(tx, op.tenantId);
await audit(tx, op, to ? 'org.ref.set' : 'org.ref.clear', 'org_unit', id, { await audit(tx, op, to ? 'org.ref.set' : 'org.ref.clear', 'org_unit', id, {
nodeId: id, nodeId: id,
......
import { prepareHostMapping, readSourceUserId, mappingConflict } from './host-user-mapping';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { createHash, randomBytes } from 'node:crypto'; import { createHash, randomBytes } from 'node:crypto';
import type { Prisma } from '../../../generated/prisma'; import type { Prisma } from '../../../generated/prisma';
...@@ -90,6 +91,7 @@ export class ProvisionService { ...@@ -90,6 +91,7 @@ export class ProvisionService {
*/ */
input: { input: {
contactId?: string; contactId?: string;
sourceUserId?: unknown;
displayName?: string; displayName?: string;
role: string; role: string;
/** 旧前端:勾的节点,一律含下级 */ /** 旧前端:勾的节点,一律含下级 */
...@@ -102,6 +104,7 @@ export class ProvisionService { ...@@ -102,6 +104,7 @@ export class ProvisionService {
rev?: unknown; rev?: unknown;
}, },
): Promise<ProvisionPreview | ProvisionResult> { ): Promise<ProvisionPreview | ProvisionResult> {
const sourceUserId = readSourceUserId(input.sourceUserId);
const role = input.role === 'staff' || input.role === 'viewer' ? input.role : null; const role = input.role === 'staff' || input.role === 'viewer' ? input.role : null;
// ⚠️ ⛔ 不允许开 owner:那等于把提权通道复制一份出去。要加 owner 走 CLI(人得想一下) // ⚠️ ⛔ 不允许开 owner:那等于把提权通道复制一份出去。要加 owner 走 CLI(人得想一下)
if (!role) { if (!role) {
...@@ -160,6 +163,7 @@ export class ProvisionService { ...@@ -160,6 +163,7 @@ export class ProvisionService {
if (input.dryRun) { if (input.dryRun) {
return runTx(this.prisma, async (tx) => { return runTx(this.prisma, async (tx) => {
const rev = await shareLockOrg(tx, admin.tenantId); const rev = await shareLockOrg(tx, admin.tenantId);
if (sourceUserId) await prepareHostMapping(tx, admin, sourceUserId);
const { next, nodes } = await this.planGrants(tx, admin, anchors); const { next, nodes } = await this.planGrants(tx, admin, anchors);
const vis = visibleOf(nodes, next); const vis = visibleOf(nodes, next);
return { return {
...@@ -185,6 +189,7 @@ export class ProvisionService { ...@@ -185,6 +189,7 @@ export class ProvisionService {
// ⚠️ 先锁再重算授权 —— ⛔ 信预演的结果(预演之后别人可能改过树或他的授权) // ⚠️ 先锁再重算授权 —— ⛔ 信预演的结果(预演之后别人可能改过树或他的授权)
const cur = await lockOrg(tx, admin.tenantId); const cur = await lockOrg(tx, admin.tenantId);
if (input.rev !== undefined) assertRev(cur, input.rev); 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 { next } = await this.planGrants(tx, admin, anchors);
const u = await tx.user.create({ const u = await tx.user.create({
data: { data: {
...@@ -197,6 +202,7 @@ export class ProvisionService { ...@@ -197,6 +202,7 @@ export class ProvisionService {
}, },
select: { id: true }, select: { id: true },
}); });
if (provider && sourceUserId) await tx.userIdentity.create({ data: { provider, externalId: sourceUserId, userId: u.id } });
if (next.length) { if (next.length) {
await tx.userOrgScope.createMany({ await tx.userOrgScope.createMany({
data: next.map((a) => ({ data: next.map((a) => ({
...@@ -242,11 +248,11 @@ export class ProvisionService { ...@@ -242,11 +248,11 @@ export class ProvisionService {
actorType: 'human', actorType: 'human',
actorUserId: admin.id, actorUserId: admin.id,
// ⛔ `changes` 里**不许有票的明文** —— 它是凭据 // ⛔ `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 }; return { userId: u.id, rev, granted: next.length };
}); }).catch(mappingConflict);
this.logger.log(`开户 ${displayName}(${role}) by=${admin.displayName} 授权=${granted} 条`); this.logger.log(`开户 ${displayName}(${role}) by=${admin.displayName} 授权=${granted} 条`);
return { applied: true, rev, userId, displayName, token, expiresAt: expiresAt.toISOString() }; 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 { setTenantRole } from '../../common/role-grants';
import { IntegrationPolicyService } from '../integration/integration-policy.service'; import { IntegrationPolicyService } from '../integration/integration-policy.service';
import { Injectable, Logger, Optional } from '@nestjs/common'; import { Injectable, Logger, Optional } from '@nestjs/common';
...@@ -50,6 +52,7 @@ const FILTERS = ['all', 'unactivated', 'ungranted', 'tenant'] as const; ...@@ -50,6 +52,7 @@ const FILTERS = ['all', 'unactivated', 'ungranted', 'tenant'] as const;
*/ */
const AUDIT_ACTIONS = [ const AUDIT_ACTIONS = [
'user.provision', 'user.provision',
'user.host.map',
'user.activate', 'user.activate',
'user.activation.reissue', 'user.activation.reissue',
'user.scope.set', 'user.scope.set',
...@@ -161,6 +164,29 @@ export class UserAdminService { ...@@ -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> { async setScopes(op: AuthUser, id: string, body: Record<string, unknown>): Promise<ScopeChangeResult> {
if (!hasPermission(op, PERMISSIONS.USER_SCOPE_GRANT)) { if (!hasPermission(op, PERMISSIONS.USER_SCOPE_GRANT)) {
throw new BizError(AiApiCode.AUTH_PERMISSION_DENIED, '你没有改授权的权限'); throw new BizError(AiApiCode.AUTH_PERMISSION_DENIED, '你没有改授权的权限');
...@@ -378,6 +404,8 @@ export class UserAdminService { ...@@ -378,6 +404,8 @@ export class UserAdminService {
try { try {
await runTx(this.prisma, async (tx) => { await runTx(this.prisma, async (tx) => {
await tx.$queryRaw`SELECT id FROM app.users WHERE id=${id}::uuid FOR UPDATE`; 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, '平台账号由平台管理'); if (await tx.userRoleGrant.findUnique({where:{userId_scope:{userId:id,scope:'platform'}}})) throw new BizError(AiApiCode.AUTH_PERMISSION_DENIED, '平台账号由平台管理');
// ⚠️ 先作废旧票再建新票:uq_ua_live 只许一张没用过的票 // ⚠️ 先作废旧票再建新票:uq_ua_live 只许一张没用过的票
const revoked = await tx.userActivation.updateMany({ const revoked = await tx.userActivation.updateMany({
...@@ -435,7 +463,9 @@ export class UserAdminService { ...@@ -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 (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, '这个账号已停用'); if (!u.isActive) throw new BizError(AiApiCode.CLIENT_VALIDATION_FAILED, '这个账号已停用');
const { passwordHash, ...rest } = u; 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 { ...@@ -465,6 +495,10 @@ export class UserAdminService {
where: { tenantId: op.tenantId }, 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 }, 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 externalLogins = this.integration ? await this.integration.hostLoginUsers(op.tenantId, users.map((u) => u.id)) : new Set<string>();
const scopes = await this.prisma.userOrgScope.findMany({ const scopes = await this.prisma.userOrgScope.findMany({
where: { tenantId: op.tenantId }, where: { tenantId: op.tenantId },
...@@ -520,6 +554,9 @@ export class UserAdminService { ...@@ -520,6 +554,9 @@ export class UserAdminService {
accountCount: accountCount.get(u.id) ?? 0, accountCount: accountCount.get(u.id) ?? 0,
self, self,
manageable, 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 }; return { rows, snap };
...@@ -531,6 +568,8 @@ function summarize(action: string, changes: unknown): string { ...@@ -531,6 +568,8 @@ function summarize(action: string, changes: unknown): string {
const c = (changes ?? {}) as Record<string, unknown>; const c = (changes ?? {}) as Record<string, unknown>;
const len = (x: unknown) => (Array.isArray(x) ? x.length : 0); const len = (x: unknown) => (Array.isArray(x) ? x.length : 0);
switch (action) { switch (action) {
case 'user.host.map':
return '关联了宿主账号';
case 'user.provision': case 'user.provision':
return '开户'; return '开户';
case 'user.scope.set': case 'user.scope.set':
......
...@@ -62,9 +62,16 @@ export class UserController { ...@@ -62,9 +62,16 @@ export class UserController {
anchors: readAnchors(b.anchors), anchors: readAnchors(b.anchors),
dryRun: readBool(b.dryRun, 'dryRun'), dryRun: readBool(b.dryRun, 'dryRun'),
rev: b.rev, 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) @RequirePermission(PERMISSIONS.USER_SCOPE_GRANT)
@Put(':id/scopes') @Put(':id/scopes')
setScopes(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() body: Record<string, unknown>) { setScopes(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() body: Record<string, unknown>) {
......
...@@ -66,6 +66,46 @@ it('应用/集团角色准入与原有 AI 授权取交集,不接受 owner/lead ...@@ -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:'owner'}}).success).toBe(false);
expect(tokenRequestSchema.safeParse({...input(),user:{...input().user,role:'leader'}}).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()=>{ it('应用凭据摘要校验从 AI 表读取,Bearer 字符串不能当 JSON secret 使用',async()=>{
const {sha256}=await import('../src/modules/integration/integration.schema'); const {sha256}=await import('../src/modules/integration/integration.schema');
const registry=new IntegrationRegistryService({integrationApp:{findUnique:async()=>({...app,secretHashes:[sha256('secret')]})}} as never); const registry=new IntegrationRegistryService({integrationApp:{findUnique:async()=>({...app,secretHashes:[sha256('secret')]})}} as never);
......
...@@ -203,10 +203,11 @@ export function OrgNodeDetail({ ...@@ -203,10 +203,11 @@ export function OrgNodeDetail({
) : null} ) : null}
</Section> </Section>
<Section title="患者档案" note="查患者资料、自动生成预约确认,都按这里关联的去找"> <Section title="患者档案" note="关联宿主中的对应组织,患者资料和免登门店识别沿用此映射">
<Field label="现在"> <Field label="现在">
<span className={`text-[12px] ${node.pacRef || node.patientReadable ? 'text-slate-700' : 'text-amber-700'}`}>{pacNow}</span> <span className={`text-[12px] ${node.pacRef || node.patientReadable ? 'text-slate-700' : 'text-amber-700'}`}>{pacNow}</span>
</Field> </Field>
{node.pacRef ? <Field label="宿主组织 ID"><span className="text-[12px] break-all">{node.pacRef}</span></Field> : null}
{edit ? ( {edit ? (
!node.isActive ? ( !node.isActive ? (
<Muted>已停用的组织不能改患者档案,请先恢复它</Muted> <Muted>已停用的组织不能改患者档案,请先恢复它</Muted>
......
...@@ -11,6 +11,7 @@ import { ...@@ -11,6 +11,7 @@ import {
type RoleChangeResult, type RoleChangeResult,
type ScopeChangeResult, type ScopeChangeResult,
} from '@/lib/api'; } from '@/lib/api';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { notify } from '@/components/ui/sonner'; import { notify } from '@/components/ui/sonner';
...@@ -67,6 +68,9 @@ export function PersonDetail({ ...@@ -67,6 +68,9 @@ export function PersonDetail({
const [ticket, setTicket] = useState<ActivationTicket | null>(null); const [ticket, setTicket] = useState<ActivationTicket | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [savingProfile, setSavingProfile] = 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; const { reset: resetScope } = scopeFlow;
...@@ -178,9 +182,23 @@ export function PersonDetail({ ...@@ -178,9 +182,23 @@ export function PersonDetail({
/> />
) : null} ) : 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="激活"> <Field label="激活">
<div className="flex items-center gap-2 text-[12px]"> <div className="flex items-center gap-2 text-[12px]">
{detail.activated ? ( {detail.activated && !detail.canReissueActivation ? (
'已激活' '已激活'
) : ( ) : (
<> <>
......
...@@ -47,6 +47,7 @@ export function ProvisionDrawer({ ...@@ -47,6 +47,7 @@ export function ProvisionDrawer({
}) { }) {
const [role, setRole] = useState<'staff' | 'viewer'>(contact ? 'staff' : 'viewer'); const [role, setRole] = useState<'staff' | 'viewer'>(contact ? 'staff' : 'viewer');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [sourceUserId, setSourceUserId] = useState('');
const [anchors, setAnchors] = useState<AnchorInput[]>([]); const [anchors, setAnchors] = useState<AnchorInput[]>([]);
const [nodes, setNodes] = useState<GrantableNode[] | null>(null); const [nodes, setNodes] = useState<GrantableNode[] | null>(null);
/** 开完之后拿到的一次性链接。⚠️ **只出现这一次** —— 关掉就再也看不到了 */ /** 开完之后拿到的一次性链接。⚠️ **只出现这一次** —— 关掉就再也看不到了 */
...@@ -69,12 +70,13 @@ export function ProvisionDrawer({ ...@@ -69,12 +70,13 @@ export function ProvisionDrawer({
// ⚠️ 预演之后又改了输入 ⇒ 那份后果已经不对了,收起来重新预演 // ⚠️ 预演之后又改了输入 ⇒ 那份后果已经不对了,收起来重新预演
useEffect(() => { useEffect(() => {
reset(); reset();
}, [role, name, anchors, reset]); }, [role, name, sourceUserId, anchors, reset]);
const body = { const body = {
contactId: contact?.id, contactId: contact?.id,
displayName: contact ? undefined : name.trim(), displayName: contact ? undefined : name.trim(),
role, role,
sourceUserId: sourceUserId.trim() || undefined,
anchors, anchors,
}; };
...@@ -114,7 +116,7 @@ export function ProvisionDrawer({ ...@@ -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"> <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 /> <br />
这是一次性的开户链接,只转给<b>本人</b>。<b>30 分钟</b>后失效,用过一次也会失效。 这是一次性的开户链接,只转给<b>本人</b>。<b>30 分钟</b>后失效,用过一次也会失效。
</div> </div>
...@@ -176,6 +178,12 @@ export function ProvisionDrawer({ ...@@ -176,6 +178,12 @@ export function ProvisionDrawer({
) : null} ) : null}
<div> <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="mb-1.5 text-[11px] font-semibold text-slate-500">角色</div>
<div className="flex gap-2"> <div className="flex gap-2">
{/* ⛔ 这里开不出管理员 —— 那等于把提权通道复制一份出去 */} {/* ⛔ 这里开不出管理员 —— 那等于把提权通道复制一份出去 */}
...@@ -222,7 +230,7 @@ export function ProvisionDrawer({ ...@@ -222,7 +230,7 @@ export function ProvisionDrawer({
</div> </div>
{copy ? ( {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"> <div className="flex gap-2">
{/* ⚠️ 姓名没填就禁用 —— 后端也判(2–32 字),⛔ 前端这道只是别让人白跑一趟 */} {/* ⚠️ 姓名没填就禁用 —— 后端也判(2–32 字),⛔ 前端这道只是别让人白跑一趟 */}
......
...@@ -174,6 +174,7 @@ export const api = { ...@@ -174,6 +174,7 @@ export const api = {
setUserScopes: (id: string, body: { anchors: AnchorInput[]; rev?: number; dryRun: boolean }) => setUserScopes: (id: string, body: { anchors: AnchorInput[]; rev?: number; dryRun: boolean }) =>
api.put<ScopeChangeResult>(`/users/${encodeURIComponent(id)}/scopes`, body), api.put<ScopeChangeResult>(`/users/${encodeURIComponent(id)}/scopes`, body),
/** ⚠️ 缺键 = 不改,`null` = 清空 */ /** ⚠️ 缺键 = 不改,`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 }) => setUserProfile: (id: string, body: { orgUnitId?: string | null; jobTitle?: string | null }) =>
api.patch<ProfileSetResult>(`/users/${encodeURIComponent(id)}/profile`, body), api.patch<ProfileSetResult>(`/users/${encodeURIComponent(id)}/profile`, body),
setUserRole: (id: string, body: { role: 'staff' | 'viewer'; dryRun: boolean }) => setUserRole: (id: string, body: { role: 'staff' | 'viewer'; dryRun: boolean }) =>
...@@ -185,6 +186,7 @@ export const api = { ...@@ -185,6 +186,7 @@ export const api = {
provision: (body: { provision: (body: {
contactId?: string; contactId?: string;
displayName?: string; displayName?: string;
sourceUserId?: string;
role: 'staff' | 'viewer'; role: 'staff' | 'viewer';
anchors: AnchorInput[]; anchors: AnchorInput[];
dryRun: boolean; dryRun: boolean;
...@@ -1700,6 +1702,9 @@ export interface AdminCan { ...@@ -1700,6 +1702,9 @@ export interface AdminCan {
} }
export interface AdminUserRow { export interface AdminUserRow {
sourceUserId?: string | null;
canMapHostUser?: boolean;
canReissueActivation?: boolean;
id: string; id: string;
displayName: string; displayName: string;
role: string; role: string;
......
...@@ -7,13 +7,51 @@ ...@@ -7,13 +7,51 @@
平台管理首期提供: 平台管理首期提供:
- 客户列表、员工与企微号查询。 - 客户列表、员工与企微号查询。
- 客户开户:数据来源、组织根、首位客户管理员;激活后由管理员搭建组织。
- 托管席位开通、续期、指定期限、永久、停用。 - 托管席位开通、续期、指定期限、永久、停用。
- 给员工授权首次扫码;默认 24 小时。 - 给员工授权首次扫码;默认 24 小时。
- 平台操作记录:操作人、目标客户、动作、原因、结果;最近 100 条。 - 平台操作记录:操作人、目标客户、动作、原因、结果;最近 100 条。
开通一次新增一个席位。页面与 `ai:hosting` CLI 共用 `HostingAdminService`,不会因入口不同改变规则。解除托管、员工转交仍在原工作台操作。 开通一次新增一个席位。页面与 `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