Commit fd0a3336 by luoqi

fix(friday-ai): allow mapped customer admins to configure unlinked clinics

parent b7901c93
Pipeline #3851 failed in 0 seconds
...@@ -417,6 +417,7 @@ export class AuthController { ...@@ -417,6 +417,7 @@ export class AuthController {
...(user.hostScope ? { hostContext: { ...(user.hostScope ? { hostContext: {
appId: user.hostScope.appId, dataTenantId: user.hostScope.dataTenantId, appId: user.hostScope.appId, dataTenantId: user.hostScope.dataTenantId,
orgScope: user.hostScope.orgScope, clinicId: user.hostScope.clinicId, orgScope: user.hostScope.orgScope, clinicId: user.hostScope.clinicId,
unmappedClinicRef: user.hostScope.unmappedClinicRef,
modules: user.hostScope.modules, capabilities: user.hostScope.capabilities, readOnly: user.hostScope.readOnly, modules: user.hostScope.modules, capabilities: user.hostScope.capabilities, readOnly: user.hostScope.readOnly,
} } : {}), } } : {}),
/** /**
......
...@@ -17,6 +17,7 @@ import { ...@@ -17,6 +17,7 @@ import {
type TenantIntegrationPolicy, type TenantIntegrationPolicy,
} from './integration.schema'; } from './integration.schema';
import { expandSubtree } from '../scope/org-tree'; import { expandSubtree } from '../scope/org-tree';
import { permissionsOf, PERMISSIONS } from '../../common/permissions';
export type IntegrationDb = Pick< export type IntegrationDb = Pick<
Prisma.TransactionClient, Prisma.TransactionClient,
| 'tenant' | 'tenant'
...@@ -204,6 +205,103 @@ export class IntegrationPolicyService { ...@@ -204,6 +205,103 @@ export class IntegrationPolicyService {
...new Set(graph.scopeOrgIds.flatMap((id) => [...expandSubtree(graph.nodes, [id])])), ...new Set(graph.scopeOrgIds.flatMap((id) => [...expandSubtree(graph.nodes, [id])])),
].filter((id) => graph.nodes.find((n) => n.id === id)?.isActive); ].filter((id) => graph.nodes.find((n) => n.id === id)?.isActive);
} }
/** 当前诊所是初始选择;已人工授权的全客户管理员可先配置尚未关联的门店。 */
async clinicSelection(
app: IntegrationAppConfig,
graph: ResolvedTenant,
input: SsoUser,
db: IntegrationDb = this.prisma,
effective?: { userId: string; orgUnitIds: string[]; capabilities: string[] },
): Promise<{ clinicId?: string; unmappedClinicRef?: string }> {
if (!input.clinicId) return {};
const id = graph.mappings.get(input.clinicId);
const allowed = effective?.orgUnitIds ?? this.expand(graph);
if (id) {
const node = graph.nodes.find((n) => n.id === id);
if (!node?.isActive || node.kind !== 'clinic' || !allowed.includes(id))
throw new BizError(12806, '当前诊所已停用、类型无效或不在授权范围内');
return { clinicId: id };
}
const missing = () => new BizError(12806, '当前诊所尚未关联,请联系客户管理员完成门店配置');
const capabilities =
effective?.capabilities ?? this.role(app, graph.policy, input).capabilities;
const management = [PERMISSIONS.ORG_TREE_EDIT, PERMISSIONS.USER_SCOPE_GRANT];
if (
input.role !== 'admin' ||
!app.modules.includes('workbench') ||
!graph.policy.modules.includes('workbench') ||
!graph.scopeOrgIds.includes(graph.policy.rootOrgId) ||
!management.every((p) => capabilities.includes(p)) ||
!graph.nodes.filter((n) => n.isActive).every((n) => allowed.includes(n.id))
)
throw missing();
const identity = await db.userIdentity.findUnique({
where: {
provider_externalId: {
provider: identityProvider(app.appId, graph.row.id),
externalId: input.userId,
},
},
select: {
user: {
select: {
id: true,
tenantId: true,
role: true,
dataScope: true,
isActive: true,
mustChangePassword: true,
integrationManagedRole: true,
},
},
},
});
const user = identity?.user;
if (
!user ||
user.tenantId !== graph.row.id ||
(effective && user.id !== effective.userId) ||
!user.isActive ||
user.mustChangePassword ||
user.integrationManagedRole ||
user.dataScope !== 'tenant' ||
!management.every((p) => permissionsOf(user.role).includes(p))
)
throw missing();
// ⛔ 不把已归属其他客户的诊所当作待配置;包含停用客户及规则中的映射。
const others = await db.tenant.findMany({
where: {
id: { not: graph.row.id },
partnerCode: app.hostCode,
partnerTenantId: input.tenantId,
},
select: { id: true, integrationPolicy: true, partnerOrgRootRef: true },
});
const refs = others.length
? await db.orgUnitRef.findMany({
where: {
tenantId: { in: others.map((t) => t.id) },
hostCode: app.hostCode,
externalId: input.clinicId,
},
select: { externalId: true },
})
: [];
if (
refs.length ||
others.some((t) => {
const policy = tenantPolicySchema.safeParse(t.integrationPolicy);
return (
t.partnerOrgRootRef === input.clinicId ||
(policy.success && Object.hasOwn(policy.data.organizationMappings, input.clinicId!))
);
})
)
throw new BizError(12806, '当前诊所不在本客户授权范围内');
return { unmappedClinicRef: input.clinicId };
}
async scope( async scope(
app: IntegrationAppConfig, app: IntegrationAppConfig,
graph: ResolvedTenant, graph: ResolvedTenant,
...@@ -238,13 +336,9 @@ export class IntegrationPolicyService { ...@@ -238,13 +336,9 @@ export class IntegrationPolicyService {
const capabilities = context.capabilityCeiling.filter( const capabilities = context.capabilityCeiling.filter(
(p) => role.capabilities.includes(p) && rights.includes(p), (p) => role.capabilities.includes(p) && rights.includes(p),
); );
let clinicId: string | undefined; const selection = await this.clinicSelection(app, graph, input, db, {
if (input.clinicId) { userId, orgUnitIds, capabilities,
clinicId = graph.mappings.get(input.clinicId); });
const n = graph.nodes.find((n) => n.id === clinicId);
if (!n?.isActive || n.kind !== 'clinic' || !orgUnitIds.includes(n.id))
throw new BizError(12806, '当前诊所不在有效授权范围内');
}
const clinicNames: Record<string, string> = {}; const clinicNames: Record<string, string> = {};
for (const [ref, name] of Object.entries(input.dictionary?.clinics ?? {})) { for (const [ref, name] of Object.entries(input.dictionary?.clinics ?? {})) {
const id = graph.mappings.get(ref); const id = graph.mappings.get(ref);
...@@ -257,7 +351,8 @@ export class IntegrationPolicyService { ...@@ -257,7 +351,8 @@ export class IntegrationPolicyService {
return { return {
...context, ...context,
scopeOrgIds: [...graph.scopeOrgIds], scopeOrgIds: [...graph.scopeOrgIds],
clinicId, clinicId: selection.clinicId,
unmappedClinicRef: selection.unmappedClinicRef,
orgUnitIds, orgUnitIds,
capabilities, capabilities,
clinicNames, clinicNames,
......
...@@ -156,6 +156,8 @@ export interface HostSessionContext { ...@@ -156,6 +156,8 @@ export interface HostSessionContext {
dictionary?: z.infer<typeof dictionarySchema>; dictionary?: z.infer<typeof dictionarySchema>;
} }
export interface HostScope extends HostSessionContext { export interface HostScope extends HostSessionContext {
/** 尚未关联的宿主诊所,仅供已授权管理员配置提示,不参与授权或患者查询。 */
unmappedClinicRef?: string;
orgUnitIds: string[]; orgUnitIds: string[];
capabilities: string[]; capabilities: string[];
modules: string[]; modules: string[];
......
...@@ -38,16 +38,7 @@ export class IntegrationService { ...@@ -38,16 +38,7 @@ export class IntegrationService {
await integrationLock(tx, app.appId, 'space:' + user.tenantId); await integrationLock(tx, app.appId, 'space:' + user.tenantId);
const graph = await this.policy.resolve(app, clean, tx); const graph = await this.policy.resolve(app, clean, tx);
this.policy.role(app, graph.policy, clean); this.policy.role(app, graph.policy, clean);
if (clean.clinicId) { await this.policy.clinicSelection(app, graph, clean, tx);
const id = graph.mappings.get(clean.clinicId),
node = graph.nodes.find((n) => n.id === id);
if (
!node?.isActive ||
node.kind !== 'clinic' ||
!this.policy.expand(graph).includes(node.id)
)
throw new BizError(12806, '当前诊所不在请求组织范围内');
}
await integrationLock(tx, app.appId, graph.row.id); await integrationLock(tx, app.appId, graph.row.id);
const id = randomUUID(), const id = randomUUID(),
nonce = randomBytes(32).toString('base64url'), nonce = randomBytes(32).toString('base64url'),
...@@ -295,7 +286,9 @@ export class IntegrationService { ...@@ -295,7 +286,9 @@ export class IntegrationService {
dataTenantId: scope.dataTenantId, dataTenantId: scope.dataTenantId,
orgScope: scope.orgScope, orgScope: scope.orgScope,
clinicId: scope.clinicId, clinicId: scope.clinicId,
entryPath: scope.modules.includes('workbench') ? '/' : '/agents/appointment-confirm', unmappedClinicRef: scope.unmappedClinicRef,
entryPath: scope.unmappedClinicRef ? '/admin/org'
: scope.modules.includes('workbench') ? '/' : '/agents/appointment-confirm',
}; };
} }
@Cron('0 25 3 * * *') async cleanup(): Promise<void> { @Cron('0 25 3 * * *') async cleanup(): Promise<void> {
......
import { IntegrationPolicyService } from '../src/modules/integration/integration-policy.service';
import { IntegrationService } from '../src/modules/integration/integration.service';
import {
identityProvider,
roleCapabilities,
tenantPolicySchema,
type HostSessionContext,
type IntegrationAppConfig,
type SsoUser,
} from '../src/modules/integration/integration.schema';
const root = '11111111-1111-4111-8111-111111111111';
const clinic = '22222222-2222-4222-8222-222222222222';
function fixture() {
const app: IntegrationAppConfig = {
appId: 'test-app',
hostCode: 'test-host',
enabled: true,
secretHashes: [],
webBaseUrl: 'https://ai.example.invalid',
allowedRoles: ['admin', 'staff', 'viewer'],
capabilities: roleCapabilities('owner'),
modules: ['workbench'],
maxSessionSeconds: 28800,
requestsPerMinute: 120,
};
const owner = {
id: 'owner',
tenantId: 'customer',
role: 'owner',
dataScope: 'tenant',
isActive: true,
mustChangePassword: false,
integrationManagedRole: false,
};
const graph = {
row: { id: 'customer' },
policy: tenantPolicySchema.parse({
version: 2,
rootOrgId: root,
allowedRoles: app.allowedRoles,
capabilities: app.capabilities,
modules: app.modules,
}),
nodes: [{ id: root, parentId: null as string | null, kind: 'brand', isActive: true }],
mappings: new Map([['source-brand', root]]),
scopeOrgIds: [root],
};
const input: SsoUser = {
userId: 'source-owner',
tenantId: 'space',
role: 'admin',
orgScope: ['source-brand'],
clinicId: 'source-clinic',
};
const context: HostSessionContext = {
version: 2,
appId: app.appId,
dataTenantId: 'space',
sourceUserId: input.userId,
launchId: root,
rootOrgId: root,
scopeOrgIds: [root],
orgScope: input.orgScope,
role: 'admin',
capabilityCeiling: app.capabilities,
readonlyCeiling: false,
};
const db = {
user: { findFirst: jest.fn(async () => owner) },
userOrgScope: { findMany: jest.fn(async () => [{ orgUnitId: root, includeSubtree: true }]) },
userIdentity: {
findUnique: jest.fn(
async (): Promise<{ userId: string; user: typeof owner } | null> => ({
userId: owner.id,
user: owner,
}),
),
},
tenant: {
findMany: jest.fn(
async (): Promise<
{ id: string; integrationPolicy: unknown; partnerOrgRootRef?: string }[]
> => [],
),
},
orgUnitRef: { findMany: jest.fn(async (): Promise<{ externalId: string }[]> => []) },
integrationAuthorization: { create: jest.fn(async () => ({})) },
$queryRaw: jest.fn(async () => []),
$transaction: jest.fn(
async (fn: (tx: unknown) => Promise<unknown>): Promise<unknown> => fn(db),
),
};
const policy = new IntegrationPolicyService(db as never, {} as never);
const selection = (u = input) => policy.clinicSelection(app, graph as never, u);
const scope = (u = input) => policy.scope(app, graph as never, owner.id, u, context);
return { app, owner, graph, input, context, db, policy, selection, scope };
}
it('已映射的全客户管理员无需独立密码激活即可先配置当前门店;未知编号不进入授权集合', async () => {
const f = fixture();
expect(await f.selection()).toEqual({ unmappedClinicRef: 'source-clinic' });
expect(f.db.userIdentity.findUnique).toHaveBeenCalledWith(
expect.objectContaining({
where: {
provider_externalId: {
provider: identityProvider(f.app.appId, 'customer'),
externalId: 'source-owner',
},
},
}),
);
const scope = await f.scope();
expect(scope).toMatchObject({
wholeTenant: true,
accountAll: true,
unmappedClinicRef: 'source-clinic',
orgUnitIds: [root],
});
expect(scope.clinicId).toBeUndefined();
expect(scope.capabilities).toContain('org.tree.edit');
});
it('签发入口使用同一诊所校验;普通员工不能取得待配置进入码', async () => {
const f = fixture();
jest.spyOn(f.policy, 'resolve').mockResolvedValue(f.graph as never);
const service = new IntegrationService(
f.db as never,
{ entryCode: () => 'test-code' } as never,
{} as never,
f.policy,
{} as never,
);
await expect(service.token(f.app, f.input)).resolves.toMatchObject({ code: 'test-code' });
expect(f.db.integrationAuthorization.create).toHaveBeenCalledTimes(1);
f.db.integrationAuthorization.create.mockClear();
await expect(service.token(f.app, { ...f.input, role: 'staff' })).rejects.toMatchObject({
code: 12806,
});
expect(f.db.integrationAuthorization.create).not.toHaveBeenCalled();
});
it.each(['staff', 'viewer'] as const)(
'宿主 %s 即使关联 owner 也不能使用门店配置例外',
async (role) => {
const f = fixture();
await expect(f.selection({ ...f.input, role })).rejects.toMatchObject({ code: 12806 });
await expect(f.scope({ ...f.input, role })).rejects.toMatchObject({ code: 12806 });
},
);
it.each([
['停用', { isActive: false }],
['必须激活的旧账号', { mustChangePassword: true }],
['宿主管理角色', { integrationManagedRole: true }],
['局部管理员', { dataScope: 'scoped' }],
['普通员工', { role: 'staff' }],
['其他客户账号', { tenantId: 'other-customer' }],
] as const)('%s不能使用门店配置例外', async (_label, changes) => {
const f = fixture();
Object.assign(f.owner, changes);
await expect(f.selection()).rejects.toMatchObject({ code: 12806 });
await expect(f.scope()).rejects.toMatchObject({ code: f.owner.isActive ? 12806 : 12805 });
});
it('未关联源身份时不自动创建或提升管理员', async () => {
const f = fixture();
f.db.userIdentity.findUnique.mockResolvedValue(null);
await expect(f.selection()).rejects.toMatchObject({ code: 12806 });
});
it('已有身份与当前会话用户不一致时拒绝', async () => {
const f = fixture();
f.db.userIdentity.findUnique.mockResolvedValue({
userId: 'someone-else',
user: { ...f.owner, id: 'someone-else' },
});
await expect(f.scope()).rejects.toMatchObject({ code: 12806 });
});
it('组织范围不含客户根时,不能借未知的当前诊所扩大范围', async () => {
const f = fixture();
f.graph.nodes.push({ id: clinic, parentId: root, kind: 'clinic', isActive: true });
f.graph.scopeOrgIds = [clinic];
await expect(f.selection()).rejects.toMatchObject({ code: 12806 });
});
it.each(['app', 'customer', 'session', 'module'])('%s未开放配置能力时不降级放行', async (layer) => {
const f = fixture();
if (layer === 'app') f.app.capabilities = ['workspace.read'];
if (layer === 'customer') f.graph.policy.capabilities = ['workspace.read'];
if (layer === 'session') f.context.capabilityCeiling = ['workspace.read'];
if (layer === 'module') f.app.modules = ['appointment-confirm'];
await expect(f.scope()).rejects.toMatchObject({ code: 12806 });
});
it.each(['reference', 'policy', 'customer-root'])(
'已有其他客户的%s映射时保持拒绝',
async (source) => {
const f = fixture();
f.db.tenant.findMany.mockResolvedValue([
{
id: 'other-customer',
partnerOrgRootRef: source === 'customer-root' ? 'source-clinic' : 'other-brand',
integrationPolicy: tenantPolicySchema.parse({
version: 2,
rootOrgId: clinic,
organizationMappings: source === 'policy' ? { 'source-clinic': clinic } : {},
}),
},
]);
if (source === 'reference')
f.db.orgUnitRef.findMany.mockResolvedValue([{ externalId: 'source-clinic' }]);
await expect(f.selection()).rejects.toMatchObject({
code: 12806,
msg: expect.stringContaining('本客户授权范围'),
});
await expect(f.scope()).rejects.toMatchObject({ code: 12806 });
},
);
it.each(['inactive', 'wrong-kind', 'out-of-scope'])(
'已映射诊所%s时不使用配置例外',
async (kind) => {
const f = fixture();
f.graph.nodes.push({
id: clinic,
parentId: kind === 'out-of-scope' ? null : root,
kind: kind === 'wrong-kind' ? 'brand' : 'clinic',
isActive: kind !== 'inactive',
});
f.graph.mappings.set('source-clinic', clinic);
await expect(f.selection()).rejects.toMatchObject({ code: 12806 });
},
);
it('补齐映射后同一会话恢复正确诊所;配置权限撤回后原会话不能继续使用例外', async () => {
const f = fixture();
expect((await f.scope()).unmappedClinicRef).toBe('source-clinic');
f.graph.nodes.push({ id: clinic, parentId: root, kind: 'clinic', isActive: true });
f.graph.mappings.set('source-clinic', clinic);
expect(await f.scope()).toMatchObject({
clinicId: clinic,
unmappedClinicRef: undefined,
orgUnitIds: [root, clinic],
});
f.graph.mappings.delete('source-clinic');
f.owner.role = 'staff';
await expect(f.scope()).rejects.toMatchObject({ code: 12806 });
});
...@@ -3,7 +3,8 @@ import { UserAdminService } from '../src/modules/workbench/user-admin.service'; ...@@ -3,7 +3,8 @@ import { UserAdminService } from '../src/modules/workbench/user-admin.service';
import { OrgAdminService } from '../src/modules/workbench/org-admin.service'; import { OrgAdminService } from '../src/modules/workbench/org-admin.service';
import { ScopeService } from '../src/modules/scope/scope.service'; import { ScopeService } from '../src/modules/scope/scope.service';
import type { AuthUser } from '../src/common/guards/jwt-auth.guard'; import type { AuthUser } from '../src/common/guards/jwt-auth.guard';
import { randomUUID, createHash } from 'node:crypto'; import { randomUUID, createHash, generateKeyPairSync } from 'node:crypto';
import type { ConfigService } from '@nestjs/config';
import { PrismaClient } from '../generated/prisma'; import { PrismaClient } from '../generated/prisma';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
import { PlatformOnboardingService } from '../src/modules/platform/platform-onboarding.service'; import { PlatformOnboardingService } from '../src/modules/platform/platform-onboarding.service';
...@@ -22,6 +23,11 @@ import { IntegrationPolicyService } from '../src/modules/integration/integration ...@@ -22,6 +23,11 @@ import { IntegrationPolicyService } from '../src/modules/integration/integration
import { adminCan } from '../src/modules/workbench/admin-common'; import { adminCan } from '../src/modules/workbench/admin-common';
import { ActivationService } from '../src/modules/auth/activation.service'; import { ActivationService } from '../src/modules/auth/activation.service';
import { PasswordService } from '../src/modules/auth/password.service'; import { PasswordService } from '../src/modules/auth/password.service';
import { JwtService } from '../src/modules/auth/jwt.service';
import { SessionService } from '../src/modules/auth/session.service';
import { IntegrationService } from '../src/modules/integration/integration.service';
import { IntegrationRegistryService } from '../src/modules/integration/integration-registry.service';
import { setTenantRole } from '../src/common/role-grants';
it('开户 API 仅平台管理员可访问', () => { it('开户 API 仅平台管理员可访问', () => {
expect(Reflect.getMetadata(AUTH_SCOPE, PlatformController)).toBe('platform'); expect(Reflect.getMetadata(AUTH_SCOPE, PlatformController)).toBe('platform');
for (const method of ['create', 'preview', 'applications', 'activation', 'mapOwner'] as const) for (const method of ['create', 'preview', 'applications', 'activation', 'mapOwner'] as const)
...@@ -108,6 +114,8 @@ run('可视化开户(仅本地真实数据库)', () => { ...@@ -108,6 +114,8 @@ run('可视化开户(仅本地真实数据库)', () => {
const ids = ( const ids = (
await db.tenant.findMany({ where: { partnerCode: host }, select: { id: true } }) await db.tenant.findMany({ where: { partnerCode: host }, select: { id: true } })
).map((t) => t.id); ).map((t) => t.id);
await db.integrationAuthorization.deleteMany({ where: { tenantId: { in: ids } } });
await db.authSession.deleteMany({ where: { tenantId: { in: ids } } });
await db.userIdentity.deleteMany({ where: { user: { tenantId: { in: ids } } } }); await db.userIdentity.deleteMany({ where: { user: { tenantId: { in: ids } } } });
await db.auditLog.deleteMany({ await db.auditLog.deleteMany({
where: { OR: [{ tenantId: { in: ids } }, { actorUserId: actor?.id }] }, where: { OR: [{ tenantId: { in: ids } }, { actorUserId: actor?.id }] },
...@@ -263,6 +271,64 @@ run('可视化开户(仅本地真实数据库)', () => { ...@@ -263,6 +271,64 @@ run('可视化开户(仅本地真实数据库)', () => {
const explicit = await policy.byId(restricted as never, tenant.id, tenant.partnerTenantId); const explicit = await policy.byId(restricted as never, tenant.id, tenant.partnerTenantId);
expect(explicit.policy.allowedRoles).toContain('admin'); expect(explicit.policy.allowedRoles).toContain('admin');
}); });
it('仅客户根的首位管理员无需激活即可内嵌进入、建店关联,刷新及重入继续使用同一账号', async () => {
const input = data({ sourceUserId: 'bootstrap-owner' });
const r = await svc.create(actor, input);
const registry = new IntegrationRegistryService(db as PrismaService);
const policy = new IntegrationPolicyService(db as PrismaService, registry);
const config = { getOrThrow: (key: string) => key === 'jwt' ? {
privateKeyPem: generateKeyPairSync('ed25519').privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
kid: 'local-onboarding-test', ttlSeconds: 900,
} : { absoluteSeconds: 28800, idleSeconds: 1800 } } as ConfigService;
const jwt = new JwtService(config);
const sessions = new SessionService(db as PrismaService, jwt, config, policy);
const integration = new IntegrationService(db as PrismaService, jwt, sessions, policy, registry);
const app = (await registry.get(appId))!;
const user = { userId: 'bootstrap-owner', tenantId: input.partnerTenantId, role: 'admin' as const, orgScope: ['brand'], clinicId: 'clinic' };
const enter = async () => integration.exchange((await integration.token(app, user)).code, randomUUID());
const first = await enter();
expect(first.userId).toBe(r.owner.id);
expect(first.context).toMatchObject({ entryPath: '/admin/org', unmappedClinicRef: 'clinic', clinicId: undefined });
const raw = await db.user.findUniqueOrThrow({ where: { id: r.owner.id } });
expect(raw.passwordHash).toBeNull();
expect(raw.passwordSetAt).toBeNull();
let op = (await sessions.currentHostUser(first.sessionId))!;
expect(op).toBeTruthy();
expect(adminCan(op).editTree).toBe(true);
const pacStub = { orgNodes: jest.fn().mockResolvedValue([
{ ref: 'brand', parentRef: null, kind: 'brand', name: '品牌', aliases: [] },
{ ref: 'clinic', 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 tree = await org.tree(op);
expect(tree.can.editTree).toBe(true);
const request = { parentId: tree.nodes[0]!.id, name: '待配置门店', kind: 'clinic' };
const pre = await org.createUnit(op, { ...request, dryRun: true });
const created = await org.createUnit(op, { ...request, rev: pre.rev, dryRun: false });
// 新节点参与实时授权交集,不依赖旧 /auth/me 快照。
op = (await sessions.currentHostUser(first.sessionId))!;
const link = await org.linkPac(op, created.nodeId!, { ref: 'clinic', dryRun: true });
await org.linkPac(op, created.nodeId!, { ref: 'clinic', rev: link.rev, ack: true, dryRun: false });
op = (await sessions.currentHostUser(first.sessionId))!;
expect(op.hostScope.clinicId).toBe(created.nodeId);
expect(op.hostScope.unmappedClinicRef).toBeUndefined();
await expect(sessions.refresh(first.refreshToken, randomUUID())).resolves.toMatchObject({ userId: r.owner.id });
const again = await enter();
expect(again.context).toMatchObject({ entryPath: '/', clinicId: created.nodeId, unmappedClinicRef: undefined });
expect(await db.user.count({ where: { tenantId: r.tenant.id } })).toBe(1);
expect((await db.user.findUniqueOrThrow({ where: { id: r.owner.id } })).passwordHash).toBeNull();
// 没有管理员身份的员工仍报组织未关联,不签发进入码或 JIT 建人。
const before = await db.integrationAuthorization.count({ where: { tenantId: r.tenant.id } });
await expect(integration.token(app, { ...user, userId: 'not-an-owner', role: 'staff', orgScope: ['not-yet-linked'], clinicId: 'not-yet-linked' })).rejects.toMatchObject({ code: 12806 });
expect(await db.integrationAuthorization.count({ where: { tenantId: r.tenant.id } })).toBe(before);
// 已有其他门店时仍能配置新店;管理员的配置权限撤回后,待配置会话立即失效。
const pendingUser = { ...user, clinicId: 'another-new-clinic' };
const pending = await integration.exchange((await integration.token(app, pendingUser)).code, randomUUID());
await db.$transaction((tx) => setTenantRole(tx, r.tenant.id, r.owner.id, 'staff', 'manual'));
expect(await sessions.currentHostUser(pending.sessionId)).toBeNull();
});
it('无宿主应用仍能开户;带映射必须有唯一宿主应用', async () => { it('无宿主应用仍能开户;带映射必须有唯一宿主应用', async () => {
await db.integrationApp.update({ where: { appId }, data: { enabled: false } }); await db.integrationApp.update({ where: { appId }, data: { enabled: false } });
try { try {
......
...@@ -83,6 +83,22 @@ it('首屏直接使用记住的诊所;跨页面选择不触发用户重查', a ...@@ -83,6 +83,22 @@ it('首屏直接使用记住的诊所;跨页面选择不触发用户重查', a
); );
expect(load).toHaveBeenCalledTimes(1); expect(load).toHaveBeenCalledTimes(1);
}); });
it('宿主当前门店待配置时不恢复旧店或自动选第一家,管理员仍可主动切换已授权门店', async () => {
const { store, load, readClinic } = fixture();
const me = profile();
me.hostContext = {
appId: 'a', dataTenantId: 'space', orgScope: ['brand'], unmappedClinicRef: 'not-yet-linked',
modules: ['workbench'], capabilities: ['org.tree.edit'], readOnly: false,
};
load.mockResolvedValue(me);
await store.load();
expect(store.getSnapshot().clinicId).toBeNull();
expect(readClinic).not.toHaveBeenCalled();
store.pickClinic('remembered');
expect(store.getSnapshot().clinicId).toBe('remembered');
store.setMe(me, 'session-a');
expect(store.getSnapshot().clinicId).toBe('remembered');
});
it('过期更新期间保留机构和托管显示,更新失败也不闪为空态', async () => { it('过期更新期间保留机构和托管显示,更新失败也不闪为空态', async () => {
const { store, load, age } = fixture(); const { store, load, age } = fixture();
await store.load(); await store.load();
......
...@@ -8,6 +8,7 @@ import { OrgNodeDetail } from '@/components/admin/org-node-detail'; ...@@ -8,6 +8,7 @@ import { OrgNodeDetail } from '@/components/admin/org-node-detail';
import { OrgRootCreate } from '@/components/admin/org-root-create'; import { OrgRootCreate } from '@/components/admin/org-root-create';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { kindLabel } from '@/lib/labels'; import { kindLabel } from '@/lib/labels';
import { useWorkbenchState } from '@/components/workbench-provider';
/** /**
* 组织架构(基础设置)—— 集团 / 品牌 / 区域 / 门店这棵树**本身**。 * 组织架构(基础设置)—— 集团 / 品牌 / 区域 / 门店这棵树**本身**。
...@@ -23,14 +24,18 @@ import { kindLabel } from '@/lib/labels'; ...@@ -23,14 +24,18 @@ import { kindLabel } from '@/lib/labels';
* ⚠️ 改组织架构要求全租户档;看得见、改不了的人整页照画、控件禁用。 * ⚠️ 改组织架构要求全租户档;看得见、改不了的人整页照画、控件禁用。
*/ */
export default function AdminOrgPage() { export default function AdminOrgPage() {
return <AdminShell>{(me, guard) => <Org guard={guard} tenantName={me.tenantName} />}</AdminShell>; return <AdminShell>{(me, guard) => <Org guard={guard} tenantName={me.tenantName} unmappedClinicRef={me.hostContext?.unmappedClinicRef} />}</AdminShell>;
} }
function Org({ guard, tenantName }: { guard: (e: unknown) => void; tenantName: string }) { function Org({ guard, tenantName, unmappedClinicRef }: { guard: (e: unknown) => void; tenantName: string; unmappedClinicRef?: string }) {
const { store } = useWorkbenchState();
const [tree, setTree] = useState<OrgTreeResp | null>(null); const [tree, setTree] = useState<OrgTreeResp | null>(null);
const [sel, setSel] = useState<string | null>(null); const [sel, setSel] = useState<string | null>(null);
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
const refresh = useCallback(() => setTick((t) => t + 1), []); const refresh = useCallback(() => {
setTick((t) => t + 1);
void store.load(true);
}, [store]);
useEffect(() => { useEffect(() => {
api.orgTree().then(setTree).catch(guard); api.orgTree().then(setTree).catch(guard);
...@@ -59,6 +64,9 @@ function Org({ guard, tenantName }: { guard: (e: unknown) => void; tenantName: s ...@@ -59,6 +64,9 @@ function Org({ guard, tenantName }: { guard: (e: unknown) => void; tenantName: s
}, [tree, sel]); }, [tree, sel]);
const node = tree?.nodes.find((n) => n.id === sel) ?? null; const node = tree?.nodes.find((n) => n.id === sel) ?? null;
const needsClinic = tree?.can.editTree && unmappedClinicRef && !tree.nodes.some(
(n) => n.isActive && n.kind === 'clinic' && n.pacRef === unmappedClinicRef,
);
return ( return (
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
...@@ -126,6 +134,13 @@ function Org({ guard, tenantName }: { guard: (e: unknown) => void; tenantName: s ...@@ -126,6 +134,13 @@ function Org({ guard, tenantName }: { guard: (e: unknown) => void; tenantName: s
</aside> </aside>
<section className="flex min-w-0 flex-1 flex-col"> <section className="flex min-w-0 flex-1 flex-col">
{needsClinic ? (
<div role="status" className="border-b border-blue-100 bg-blue-50 px-4 py-3 text-[12px] leading-6 text-blue-900">
<p className="font-medium">先关联当前门店</p>
<p>你已作为客户管理员进入。请添加当前门店,并在「患者档案」中关联对应诊所;已有门店可直接选择后关联。</p>
<p className="text-blue-700">宿主诊所编号:<span className="break-all font-mono">{unmappedClinicRef}</span></p>
</div>
) : null}
{tree && node ? ( {tree && node ? (
<OrgNodeDetail key={node.id} node={node} nodes={tree.nodes} can={tree.can} pac={pac} onChanged={refresh} onSelect={setSel} /> <OrgNodeDetail key={node.id} node={node} nodes={tree.nodes} can={tree.can} pac={pac} onChanged={refresh} onSelect={setSel} />
) : tree && tree.nodes.length === 0 && tree.can.editTree ? ( ) : tree && tree.nodes.length === 0 && tree.can.editTree ? (
......
...@@ -30,7 +30,7 @@ function Field({ ...@@ -30,7 +30,7 @@ function Field({
</label> </label>
); );
} }
export function ActivationCard({ activation }: { activation: Activation }) { export function ActivationCard({ activation, hostMapped = false }: { activation: Activation; hostMapped?: boolean }) {
const [copied, setCopied] = useState(false), const [copied, setCopied] = useState(false),
[error, setError] = useState(''); [error, setError] = useState('');
const url = const url =
...@@ -39,7 +39,8 @@ export function ActivationCard({ activation }: { activation: Activation }) { ...@@ -39,7 +39,8 @@ export function ActivationCard({ activation }: { activation: Activation }) {
: `${window.location.origin}/activate?t=${encodeURIComponent(activation.token)}`; : `${window.location.origin}/activate?t=${encodeURIComponent(activation.token)}`;
return ( return (
<div className="space-y-3 rounded-xl bg-blue-50 p-4"> <div className="space-y-3 rounded-xl bg-blue-50 p-4">
<p className="font-medium">转交给管理员本人激活</p> <p className="font-medium">{hostMapped ? '按需设置独立登录' : '转交给管理员本人激活'}</p>
{hostMapped ? <p className="text-xs text-slate-600">已关联宿主账号,宿主授权允许时可直接进入配置组织,无需先设置独立登录。</p> : null}
<p className="text-xs text-slate-500"> <p className="text-xs text-slate-500">
本人打开链接后填写手机号并设置密码。有效至{' '} 本人打开链接后填写手机号并设置密码。有效至{' '}
{new Date(activation.expiresAt).toLocaleString('zh-CN')},使用一次后失效。 {new Date(activation.expiresAt).toLocaleString('zh-CN')},使用一次后失效。
...@@ -214,8 +215,7 @@ export function TenantOnboarding({ ...@@ -214,8 +215,7 @@ export function TenantOnboarding({
/> />
</Field> </Field>
<p className="rounded-xl bg-blue-50 p-4 text-slate-600"> <p className="rounded-xl bg-blue-50 p-4 text-slate-600">
开户后生成 30 关联宿主账号后,管理员可按宿主授权直接进入配置组织。需要独立登录时,再由本人通过 30 分钟有效的激活链接设置手机号和密码。
分钟有效的激活链接。由管理员本人填写手机号、设置密码,即可独立登录搭建组织。
</p> </p>
<Field <Field
label="宿主用户 ID(可选)" label="宿主用户 ID(可选)"
...@@ -263,7 +263,7 @@ export function TenantOnboarding({ ...@@ -263,7 +263,7 @@ export function TenantOnboarding({
<dt>宿主账号映射</dt> <dt>宿主账号映射</dt>
<dd>{sourceUserId || '暂未填写'}</dd> <dd>{sourceUserId || '暂未填写'}</dd>
<dt>首次登录</dt> <dt>首次登录</dt>
<dd>本人激活后登录</dd> <dd>{sourceUserId.trim() ? '按宿主授权直接进入;独立登录按需激活' : '本人激活后独立登录'}</dd>
<dt>组织数据核对</dt> <dt>组织数据核对</dt>
<dd> <dd>
{preview.dataStatus === 'verified' ? '已找到客户范围内的组织编号' : '尚未完成'} {preview.dataStatus === 'verified' ? '已找到客户范围内的组织编号' : '尚未完成'}
...@@ -288,7 +288,7 @@ export function TenantOnboarding({ ...@@ -288,7 +288,7 @@ export function TenantOnboarding({
<p className="text-base font-medium text-emerald-700">{created.tenant.name}已开通</p> <p className="text-base font-medium text-emerald-700">{created.tenant.name}已开通</p>
<p>{created.owner.displayName}已设为客户管理员,可管理本客户的组织树和员工。</p> <p>{created.owner.displayName}已设为客户管理员,可管理本客户的组织树和员工。</p>
{created.activation ? ( {created.activation ? (
<ActivationCard activation={created.activation} /> <ActivationCard activation={created.activation} hostMapped={!!sourceUserId.trim()} />
) : ( ) : (
<p className="rounded-xl bg-blue-50 p-4">请在客户详情为首位管理员生成激活链接。</p> <p className="rounded-xl bg-blue-50 p-4">请在客户详情为首位管理员生成激活链接。</p>
)} )}
......
...@@ -477,6 +477,9 @@ function ClinicSwitch({ ...@@ -477,6 +477,9 @@ function ClinicSwitch({
if (!clinics.length) { if (!clinics.length) {
// ① 还没加载完 —— ⛔ 别下结论。占一行灰字,位置不跳。 // ① 还没加载完 —— ⛔ 别下结论。占一行灰字,位置不跳。
if (!me) return <div className="truncate text-[12px] font-semibold text-slate-300">···</div>; if (!me) return <div className="truncate text-[12px] font-semibold text-slate-300">···</div>;
if (me.hostContext?.unmappedClinicRef) return (
<div className="truncate text-[12px] font-semibold text-amber-600" title="请在组织架构中添加门店并关联患者档案">待配置门店</div>
);
/** /**
* ② 真的一家都没被授权 —— 这才是要去找管理员的。 * ② 真的一家都没被授权 —— 这才是要去找管理员的。
* *
...@@ -497,15 +500,15 @@ function ClinicSwitch({ ...@@ -497,15 +500,15 @@ function ClinicSwitch({
</div> </div>
); );
} }
const cur = clinics.find((c) => c.id === clinicId) ?? clinics[0]!; const cur = clinics.find((c) => c.id === clinicId) ?? (me?.hostContext?.unmappedClinicRef ? null : clinics[0]!);
if (clinics.length === 1) { if (clinics.length === 1 && cur) {
return <div className="truncate text-[12px] font-semibold">{withInactive(cur)}</div>; return <div className="truncate text-[12px] font-semibold">{withInactive(cur)}</div>;
} }
return ( return (
<Select value={cur.id} onValueChange={onClinic}> <Select value={cur?.id ?? ''} onValueChange={onClinic}>
<SelectTrigger <SelectTrigger
aria-label="切换诊所" aria-label="切换诊所"
title={withInactive(cur)} title={cur ? withInactive(cur) : '选择诊所'}
/** /**
* ⚠️ 必须显式压掉基类里的 `h-9` / `px-3 py-2` / `border` / `bg-white` —— * ⚠️ 必须显式压掉基类里的 `h-9` / `px-3 py-2` / `border` / `bg-white` ——
* tailwind-merge 只合并**同一个属性**,不写就会在这行 10.5px 的小字里 * tailwind-merge 只合并**同一个属性**,不写就会在这行 10.5px 的小字里
...@@ -521,7 +524,7 @@ function ClinicSwitch({ ...@@ -521,7 +524,7 @@ function ClinicSwitch({
现在选项里只有店名,两种写法看起来一样 —— 但哪天往选项里加一句提示, 现在选项里只有店名,两种写法看起来一样 —— 但哪天往选项里加一句提示,
无参数那种会跟着把提示也显示到这行 12px 的字里(踩过一次)。 无参数那种会跟着把提示也显示到这行 12px 的字里(踩过一次)。
*/} */}
<SelectValue>{withInactive(cur)}</SelectValue> <SelectValue placeholder="选择诊所">{cur ? withInactive(cur) : undefined}</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{clinics.map((c) => ( {clinics.map((c) => (
......
...@@ -330,6 +330,7 @@ export function hostAllows(me: Me | null | undefined, capability: string): boole ...@@ -330,6 +330,7 @@ export function hostAllows(me: Me | null | undefined, capability: string): boole
export interface Me { export interface Me {
hostContext?: { hostContext?: {
appId: string; dataTenantId: string; orgScope: string[]; clinicId?: string; appId: string; dataTenantId: string; orgScope: string[]; clinicId?: string;
unmappedClinicRef?: string;
modules: string[]; capabilities: string[]; readOnly: boolean; modules: string[]; capabilities: string[]; readOnly: boolean;
}; };
id: string; id: string;
......
/** 所有网页登录共用:localStorage 只保存可撤销刷新凭据,访问令牌只在内存。 */ /** 所有网页登录共用:localStorage 只保存可撤销刷新凭据,访问令牌只在内存。 */
export interface HostEntryContext { export interface HostEntryContext {
appId: string; dataTenantId: string; orgScope: string[]; clinicId?: string; appId: string; dataTenantId: string; orgScope: string[]; clinicId?: string;
entryPath: '/' | '/agents/appointment-confirm'; unmappedClinicRef?: string;
entryPath: '/' | '/agents/appointment-confirm' | '/admin/org';
} }
export interface SessionTokens { export interface SessionTokens {
context?: HostEntryContext; context?: HostEntryContext;
......
...@@ -67,9 +67,11 @@ export class WorkbenchSession { ...@@ -67,9 +67,11 @@ export class WorkbenchSession {
if (expectedSid !== this.deps.sessionId()) return; if (expectedSid !== this.deps.sessionId()) return;
const old = this.getSnapshot(); const old = this.getSnapshot();
const sameUser = old.me?.id === me.id && old.me.tenantId === me.tenantId; const sameUser = old.me?.id === me.id && old.me.tenantId === me.tenantId;
const picked = sameUser ? old.clinicId : this.deps.readClinic(me, expectedSid); const picked = sameUser ? old.clinicId
const clinicId = : me.hostContext?.unmappedClinicRef ? null : this.deps.readClinic(me, expectedSid);
pickDefaultClinic(me.clinics, picked ?? me.hostContext?.clinicId ?? null)?.id ?? null; // 当前门店尚未关联时先留在组织配置,不用旧缓存或第一家门店冒充当前选择。
const clinicId = me.hostContext?.unmappedClinicRef && !picked ? null
: pickDefaultClinic(me.clinics, picked ?? me.hostContext?.clinicId ?? null)?.id ?? null;
// 内容未变时保留引用,避免业务页把后台校验误当成一次身份/范围变更。 // 内容未变时保留引用,避免业务页把后台校验误当成一次身份/范围变更。
const stableMe = old.me && JSON.stringify(old.me) === JSON.stringify(me) ? old.me : me; const stableMe = old.me && JSON.stringify(old.me) === JSON.stringify(me) ? old.me : me;
// 显式更新优先于在途旧读取(例如绑定/转交结束后回来的新数据)。 // 显式更新优先于在途旧读取(例如绑定/转交结束后回来的新数据)。
......
...@@ -8,9 +8,11 @@ icon: ListChecks ...@@ -8,9 +8,11 @@ icon: ListChecks
## 1. 确认客户与数据范围 ## 1. 确认客户与数据范围
向服务方提供机构名称、业务系统、客户组织编号和首位管理员信息。服务方核对数据归属并开通客户,管理员通过一次性激活链接设置登录信息。链接 30 分钟有效;未激活时可由平台重新生成,旧链接随即失效。 向服务方提供机构名称、业务系统、客户组织编号和首位管理员信息。服务方核对数据归属并开通客户。
独立登录使用 FridayAI 登录页;已接入宿主的机构也可从业务系统进入。两种入口的可用功能以各自授权为准。 已接入宿主并关联账号的管理员,可按宿主授权直接进入配置组织,无需先设置手机号和密码。当前门店尚未关联时,拥有全客户配置权限的管理员会先进入「组织架构」。
需要独立登录时,由管理员通过一次性激活链接设置手机号和密码。链接 30 分钟有效;未激活时可由平台重新生成,旧链接随即失效。两种入口的可用功能以各自授权为准。
## 2. 配置组织与员工 ## 2. 配置组织与员工
......
...@@ -26,7 +26,7 @@ FridayAI 通过患者数据中枢使用机构授权的患者与预约信息, ...@@ -26,7 +26,7 @@ FridayAI 通过患者数据中枢使用机构授权的患者与预约信息,
## 从宿主进入工作台 ## 从宿主进入工作台
宿主后端核验用户身份与当前权限后,申请短期进入码;FridayAI 兑换后打开已授权模块。机构和组织映射需事先开通,用户权限按 FridayAI 与宿主授权共同限制。 宿主后端核验用户身份与当前权限后,申请短期进入码;FridayAI 兑换后打开已授权模块。客户与组织授权范围需事先开通,用户权限按 FridayAI 与宿主授权共同限制。已关联的全客户管理员可先进入配置尚未关联的门店;员工需等待对应门店及授权就绪后进入。
参数、进入码、组织授权及退出处理统一见[FridayAI 宿主接入契约](/docs/integration/friday-ai-host)。 参数、进入码、组织授权及退出处理统一见[FridayAI 宿主接入契约](/docs/integration/friday-ai-host)。
......
...@@ -71,7 +71,7 @@ frame.src = `${aiWebBaseUrl}/embed?code=${encodeURIComponent(result.code)}`; ...@@ -71,7 +71,7 @@ frame.src = `${aiWebBaseUrl}/embed?code=${encodeURIComponent(result.code)}`;
// 或 window.open 同一个地址。不要把用户令牌放 URL。 // 或 window.open 同一个地址。不要把用户令牌放 URL。
``` ```
AI 页面自行兑换、移除 URL 中的 code、保存可撤销刷新凭据并恢复会话。无效新码不会回退到旧客户/旧账号。初始页面默认 AI 工作台;集团仅开预约确认模块时进入已开模块。 AI 页面自行兑换、移除 URL 中的 code、保存可撤销刷新凭据并恢复会话。无效新码不会回退到旧客户/旧账号。初始页面默认 AI 工作台;集团仅开预约确认模块时进入已开模块。符合下述门店配置条件的管理员,先进入组织架构页。
```html ```html
<iframe title="FridayAI" referrerpolicy="no-referrer" <iframe title="FridayAI" referrerpolicy="no-referrer"
...@@ -107,7 +107,11 @@ AI 自身租户为独立集团,固定客户组织根、组织树及 PAC 引用 ...@@ -107,7 +107,11 @@ AI 自身租户为独立集团,固定客户组织根、组织树及 PAC 引用
### 当前诊所与展示名 ### 当前诊所与展示名
clinicId 使用宿主诊所 ID,映射成 AI 诊所后检查是否在本次有效授权内。不是诊所、未知节点、停用或超范围时明确拒绝,不默默换到另一店。 clinicId 使用宿主诊所 ID,是初始选择,不是授权依据。已映射时必须是启用中的诊所,且在本次有效授权内;类型无效、停用或超范围返回 `12806`,不默默换到另一店。
当前诊所尚未关联时,仅以下条件同时满足才允许进入组织架构页配置门店:本次角色为 `admin`,组织范围覆盖客户根,源身份已显式关联到人工授权的全客户管理员,且应用、客户及本次会话均允许组织配置和人员授权。此时不选择其他诊所、不自动建店或绑定;管理员添加门店并关联患者档案后,原会话按新映射恢复正常诊所选择。已确认映射到其他客户的诊所仍拒绝。
普通员工或未满足上述条件的管理员,门店未关联时仍返回 `12806`。独立登录的手机号、密码激活不作为已关联宿主管理员进入的前置条件。
不传时:恢复该身份/窗口的有效选择,否则选择第一家可用诊所。新进入码提供的初始值优先旧窗口缓存;进入后可在范围内切换,刷新保留该窗口的选择。不同用户、集团和内嵌窗口不会互相改选择。 不传时:恢复该身份/窗口的有效选择,否则选择第一家可用诊所。新进入码提供的初始值优先旧窗口缓存;进入后可在范围内切换,刷新保留该窗口的选择。不同用户、集团和内嵌窗口不会互相改选择。
...@@ -214,6 +218,8 @@ orgRoot 仅为开户时配置的固定 PAC 客户根,不是每次登录参数 ...@@ -214,6 +218,8 @@ orgRoot 仅为开户时配置的固定 PAC 客户根,不是每次登录参数
| 12815 | 网页刷新竞态,可稍后重试 | | 12815 | 网页刷新竞态,可稍后重试 |
| 12816 | 无法唯一定位集团、跨集团范围或组织映射歧义 | | 12816 | 无法唯一定位集团、跨集团范围或组织映射歧义 |
`12806` 是组织校验失败,不等同于“客户未开户”,也不代表必须激活独立登录。请结合 `msg` 区分未关联、停用、超范围或归属核验暂不可用;宿主可显示可操作的配置提示,不能将所有 `12806` 降级放行。管理员的待配置入口仍须通过既有身份和客户根授权检查。
每应用实例默认 token/logout 每分钟 120 次;入口另有独立的来源 IP 和全局限流,不与手机号密码登录共用桶。多实例生产网关应叠加总配额。码消费、刷新、退出的原子性由 PostgreSQL 保证,不要求 Redis。 每应用实例默认 token/logout 每分钟 120 次;入口另有独立的来源 IP 和全局限流,不与手机号密码登录共用桶。多实例生产网关应叠加总配额。码消费、刷新、退出的原子性由 PostgreSQL 保证,不要求 Redis。
宿主须提供后端身份核验、组织授权转换、前端入口及可靠退出通知。上线前完成数据库迁移、应用与客户开通,并核对真实宿主的进入、范围限制和退出流程。预约回写另行启用;未启用时在源业务系统办理。 宿主须提供后端身份核验、组织授权转换、前端入口及可靠退出通知。上线前完成数据库迁移、应用与客户开通,并核对真实宿主的进入、范围限制和退出流程。预约回写另行启用;未启用时在源业务系统办理。
...@@ -26,6 +26,8 @@ pnpm --filter @pac/ai-service ai:platform revoke \ ...@@ -26,6 +26,8 @@ pnpm --filter @pac/ai-service ai:platform revoke \
客户开户优先使用平台「客户与托管 → 开通客户」。填写宿主、数据空间、固定客户组织根和首位管理员;源用户映射使用核实的 userId,不按姓名或手机号猜测。开户不自动导入门店、增加托管席位或启用 AI 回复。 客户开户优先使用平台「客户与托管 → 开通客户」。填写宿主、数据空间、固定客户组织根和首位管理员;源用户映射使用核实的 userId,不按姓名或手机号猜测。开户不自动导入门店、增加托管席位或启用 AI 回复。
已关联的首位管理员可按宿主 `admin` 及客户根授权直接进入,无需先激活独立登录。当前门店未关联时,全客户配置管理员进入组织架构页添加并关联门店;员工、已停用门店及其他客户的门店仍被组织校验拒绝。`12806` 应区分门店未关联与权限越界,不能统一当作未开户或一律放行。
## 托管命令 ## 托管命令
`tenant`、`user`、`account` 为 FridayAI 数据库 UUID,不能用宿主 ID 或企微 vid 替代。平台页面与 CLI 共用管理服务。 `tenant`、`user`、`account` 为 FridayAI 数据库 UUID,不能用宿主 ID 或企微 vid 替代。平台页面与 CLI 共用管理服务。
......
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