Skip to content
Projects
Groups
Snippets
Help
This project
Loading...
Sign in / Register
Toggle navigation
P
pac
Overview
Overview
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ai-tools
pac
Commits
cb08cf0e
Commit
cb08cf0e
authored
Oct 12, 2026
by
luoqi
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: add platform onboarding and owner-managed host mappings
parent
4e04aa08
Pipeline
#3758
failed in 0 seconds
Changes
25
Pipelines
2
Hide whitespace changes
Inline
Side-by-side
Showing
25 changed files
with
1727 additions
and
29 deletions
+1727
-29
apps/ai-service/src/common/permissions.ts
+2
-1
apps/ai-service/src/modules/integration/integration-policy.service.ts
+17
-4
apps/ai-service/src/modules/integration/integration.schema.ts
+6
-1
apps/ai-service/src/modules/platform/platform-onboarding.schema.ts
+23
-0
apps/ai-service/src/modules/platform/platform-onboarding.service.ts
+331
-0
apps/ai-service/src/modules/platform/platform.controller.ts
+56
-1
apps/ai-service/src/modules/platform/platform.module.ts
+9
-1
apps/ai-service/src/modules/platform/platform.service.ts
+34
-2
apps/ai-service/src/modules/workbench/admin.types.ts
+3
-0
apps/ai-service/src/modules/workbench/contact.controller.ts
+1
-0
apps/ai-service/src/modules/workbench/host-user-mapping.ts
+40
-0
apps/ai-service/src/modules/workbench/org-admin.service.ts
+9
-0
apps/ai-service/src/modules/workbench/provision.service.ts
+8
-2
apps/ai-service/src/modules/workbench/user-admin.service.ts
+40
-1
apps/ai-service/src/modules/workbench/user.controller.ts
+7
-0
apps/ai-service/tests/integration-boundary.spec.ts
+40
-0
apps/ai-service/tests/platform-onboarding.integration.spec.ts
+426
-0
apps/ai-web/src/app/platform/page.tsx
+215
-10
apps/ai-web/src/components/admin/org-node-detail.tsx
+2
-1
apps/ai-web/src/components/admin/person-detail.tsx
+19
-1
apps/ai-web/src/components/platform/tenant-onboarding.tsx
+343
-0
apps/ai-web/src/components/provision-drawer.tsx
+11
-3
apps/ai-web/src/lib/api.ts
+5
-0
docs/friday-ai-platform-management.md
+39
-1
docs/qa/2026-10-12-platform-onboarding-local.md
+41
-0
No files found.
apps/ai-service/src/common/permissions.ts
View file @
cb08cf0e
...
@@ -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
,
...
...
apps/ai-service/src/modules/integration/integration-policy.service.ts
View file @
cb08cf0e
...
@@ -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
,
'旧宿主会话已结束,请重新进入'
);
...
...
apps/ai-service/src/modules/integration/integration.schema.ts
View file @
cb08cf0e
...
@@ -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'
]),
...
...
apps/ai-service/src/modules/platform/platform-onboarding.schema.ts
0 → 100644
View file @
cb08cf0e
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
>
;
apps/ai-service/src/modules/platform/platform-onboarding.service.ts
0 → 100644
View file @
cb08cf0e
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
;
});
}
}
apps/ai-service/src/modules/platform/platform.controller.ts
View file @
cb08cf0e
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
)
{
...
...
apps/ai-service/src/modules/platform/platform.module.ts
View file @
cb08cf0e
...
@@ -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
{}
apps/ai-service/src/modules/platform/platform.service.ts
View file @
cb08cf0e
...
@@ -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
,
};
};
...
...
apps/ai-service/src/modules/workbench/admin.types.ts
View file @
cb08cf0e
...
@@ -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
;
...
...
apps/ai-service/src/modules/workbench/contact.controller.ts
View file @
cb08cf0e
...
@@ -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
,
});
});
}
}
...
...
apps/ai-service/src/modules/workbench/host-user-mapping.ts
0 → 100644
View file @
cb08cf0e
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\x
00-
\x
1f
]
/
.
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
;
}
apps/ai-service/src/modules/workbench/org-admin.service.ts
View file @
cb08cf0e
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
,
...
...
apps/ai-service/src/modules/workbench/provision.service.ts
View file @
cb08cf0e
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
()
};
...
...
apps/ai-service/src/modules/workbench/user-admin.service.ts
View file @
cb08cf0e
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'
:
...
...
apps/ai-service/src/modules/workbench/user.controller.ts
View file @
cb08cf0e
...
@@ -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
>
)
{
...
...
apps/ai-service/tests/integration-boundary.spec.ts
View file @
cb08cf0e
...
@@ -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
);
...
...
apps/ai-service/tests/platform-onboarding.integration.spec.ts
0 → 100644
View file @
cb08cf0e
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
);
});
});
apps/ai-web/src/app/platform/page.tsx
View file @
cb08cf0e
...
@@ -9,6 +9,11 @@ import { Checkbox } from '@/components/ui/checkbox';
...
@@ -9,6 +9,11 @@ import { Checkbox } from '@/components/ui/checkbox';
import
{
DateTimePicker
}
from
'@/components/ui/date-time-picker'
;
import
{
DateTimePicker
}
from
'@/components/ui/date-time-picker'
;
import
{
Dialog
,
DialogContent
}
from
'@/components/ui/dialog'
;
import
{
Dialog
,
DialogContent
}
from
'@/components/ui/dialog'
;
import
{
import
{
TenantOnboarding
,
ActivationCard
,
type
Activation
,
}
from
'@/components/platform/tenant-onboarding'
;
import
{
Select
,
Select
,
SelectContent
,
SelectContent
,
SelectItem
,
SelectItem
,
...
@@ -29,6 +34,9 @@ type User = {
...
@@ -29,6 +34,9 @@ type User = {
phone
:
string
|
null
;
phone
:
string
|
null
;
role
:
string
;
role
:
string
;
isActive
:
boolean
;
isActive
:
boolean
;
pendingActivation
:
boolean
;
canMapOwner
:
boolean
;
sourceUserId
:
string
|
null
;
};
};
type
Seat
=
{
type
Seat
=
{
id
:
string
;
id
:
string
;
...
@@ -40,7 +48,11 @@ type Seat = {
...
@@ -40,7 +48,11 @@ type Seat = {
reservationId
:
string
|
null
;
reservationId
:
string
|
null
;
};
};
type
Detail
=
{
type
Detail
=
{
tenant
:
Tenant
;
tenant
:
Tenant
&
{
partnerTenantId
:
string
;
partnerOrgRootRef
:
string
;
integrationAppId
:
string
|
null
;
};
users
:
User
[];
users
:
User
[];
accounts
:
{
id
:
string
;
nickname
:
string
|
null
}[];
accounts
:
{
id
:
string
;
nickname
:
string
|
null
}[];
seats
:
Seat
[];
seats
:
Seat
[];
...
@@ -62,6 +74,13 @@ const verbs = { grant: '开通席位', renew: '续期', authorize: '授权扫码
...
@@ -62,6 +74,13 @@ const verbs = { grant: '开通席位', renew: '续期', authorize: '授权扫码
type
Action
=
keyof
typeof
verbs
;
type
Action
=
keyof
typeof
verbs
;
export
default
function
PlatformPage
()
{
export
default
function
PlatformPage
()
{
const
router
=
useRouter
();
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
),
const
[
me
,
setMe
]
=
useState
<
Me
|
null
>
(
null
),
[
tenants
,
setTenants
]
=
useState
<
Tenant
[]
>
([]),
[
tenants
,
setTenants
]
=
useState
<
Tenant
[]
>
([]),
[
detail
,
setDetail
]
=
useState
<
Detail
|
null
>
(
null
),
[
detail
,
setDetail
]
=
useState
<
Detail
|
null
>
(
null
),
...
@@ -144,6 +163,7 @@ export default function PlatformPage() {
...
@@ -144,6 +163,7 @@ export default function PlatformPage() {
}
}
}
;
}
;
const can = me?.permissions.includes('platform.hosting.manage');
const can = me?.permissions.includes('platform.hosting.manage');
const canOnboard = me?.permissions.includes('platform.tenant.manage');
return (
return (
<
main
className=
"min-h-screen bg-slate-50 text-[12.5px] text-slate-700"
>
<
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"
>
<
header
className=
"flex h-16 items-center justify-between bg-white px-6"
>
...
@@ -158,9 +178,7 @@ export default function PlatformPage() {
...
@@ -158,9 +178,7 @@ export default function PlatformPage() {
{
me
&&
<
WorkspaceSwitch
/>
}
{
me
&&
<
WorkspaceSwitch
/>
}
<
Button
<
Button
variant=
"ghost"
variant=
"ghost"
onClick=
{
()
=>
onClick=
{
()
=>
void
api
.
post
(
'/auth/logout'
,
{}).
then
(()
=>
router
.
replace
(
'/login'
))
}
void
api
.
post
(
'/auth/logout'
,
{}).
then
(()
=>
router
.
replace
(
'/login'
))
}
>
>
退出
退出
</
Button
>
</
Button
>
...
@@ -222,6 +240,9 @@ export default function PlatformPage() {
...
@@ -222,6 +240,9 @@ export default function PlatformPage() {
'hosting.revoke'
:
'停用席位'
,
'hosting.revoke'
:
'停用席位'
,
'platform.user.grant'
:
'授予平台角色'
,
'platform.user.grant'
:
'授予平台角色'
,
'platform.user.revoke'
:
'收回平台角色'
,
'platform.user.revoke'
:
'收回平台角色'
,
'tenant.onboard'
:
'开通客户'
,
'tenant.owner.activation'
:
'重发管理员激活链接'
,
'tenant.owner.map'
:
'关联宿主账号'
,
'auth.login'
:
'登录'
,
'auth.login'
:
'登录'
,
'auth.password.change'
:
'修改密码'
,
'auth.password.change'
:
'修改密码'
,
}[
l
.
action
]
??
l
.
action
}
}[
l
.
action
]
??
l
.
action
}
...
@@ -240,12 +261,15 @@ export default function PlatformPage() {
...
@@ -240,12 +261,15 @@ export default function PlatformPage() {
<>
<>
<
div
className=
"flex items-center justify-between"
>
<
div
className=
"flex items-center justify-between"
>
<
h1
className=
"text-lg font-semibold text-slate-900"
>
客户与托管
</
h1
>
<
h1
className=
"text-lg font-semibold text-slate-900"
>
客户与托管
</
h1
>
<
Input
<
div
className=
"flex items-center gap-3"
>
className=
"max-w-64"
<
Input
placeholder=
"搜索客户名称"
className=
"max-w-64"
value=
{
q
}
placeholder=
"搜索客户名称"
onChange=
{
(
e
)
=>
setQ
(
e
.
target
.
value
)
}
value=
{
q
}
/>
onChange=
{
(
e
)
=>
setQ
(
e
.
target
.
value
)
}
/>
{
canOnboard
&&
<
Button
onClick=
{
()
=>
setOnboarding
(
true
)
}
>
开通客户
</
Button
>
}
</
div
>
</
div
>
</
div
>
<
div
className=
"overflow-x-auto rounded-xl bg-white"
>
<
div
className=
"overflow-x-auto rounded-xl bg-white"
>
<
table
className=
"w-full text-left"
>
<
table
className=
"w-full text-left"
>
...
@@ -305,6 +329,32 @@ export default function PlatformPage() {
...
@@ -305,6 +329,32 @@ export default function PlatformPage() {
{
can
?
<
Button
onClick=
{
()
=>
open
(
'grant'
,
null
)
}
>
开通一个席位
</
Button
>
:
null
}
{
can
?
<
Button
onClick=
{
()
=>
open
(
'grant'
,
null
)
}
>
开通一个席位
</
Button
>
:
null
}
</
div
>
</
div
>
<
div
className=
"rounded-xl bg-white p-5"
>
<
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
>
<
h2
className=
"mb-4 font-semibold"
>
托管席位
</
h2
>
<
p
className=
"mb-4 text-slate-400"
>
<
p
className=
"mb-4 text-slate-400"
>
每次开通新增一个席位;解除托管不会释放已占用席位。首次扫码授权默认有效 24 小时。
每次开通新增一个席位;解除托管不会释放已占用席位。首次扫码授权默认有效 24 小时。
...
@@ -377,6 +427,35 @@ export default function PlatformPage() {
...
@@ -377,6 +427,35 @@ export default function PlatformPage() {
{
{
owner
:
'管理员'
,
staff
:
'员工'
,
viewer
:
'只读'
}[
u
.
role
]
??
u
.
role
}
{
{
owner
:
'管理员'
,
staff
:
'员工'
,
viewer
:
'只读'
}[
u
.
role
]
??
u
.
role
}
</
span
>
</
span
>
<
span
>
{
u
.
isActive
?
'正常'
:
'已停用'
}
</
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
>
))
}
))
}
</
div
>
</
div
>
...
@@ -385,6 +464,132 @@ export default function PlatformPage() {
...
@@ -385,6 +464,132 @@ export default function PlatformPage() {
</
section
>
</
section
>
</
div
>
</
div
>
<
Dialog
<
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
}
open=
{
!!
action
}
onOpenChange=
{
(
v
)
=>
{
onOpenChange=
{
(
v
)
=>
{
if
(
!
v
&&
!
busy
)
setAction
(
null
);
if
(
!
v
&&
!
busy
)
setAction
(
null
);
...
...
apps/ai-web/src/components/admin/org-node-detail.tsx
View file @
cb08cf0e
...
@@ -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
>
...
...
apps/ai-web/src/components/admin/person-detail.tsx
View file @
cb08cf0e
...
@@ -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
?
(
'已激活'
'已激活'
)
:
(
)
:
(
<>
<>
...
...
apps/ai-web/src/components/platform/tenant-onboarding.tsx
0 → 100644
View file @
cb08cf0e
'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
>
);
}
apps/ai-web/src/components/provision-drawer.tsx
View file @
cb08cf0e
...
@@ -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 字),⛔ 前端这道只是别让人白跑一趟 */
}
...
...
apps/ai-web/src/lib/api.ts
View file @
cb08cf0e
...
@@ -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
;
...
...
docs/friday-ai-platform-management.md
View file @
cb08cf0e
...
@@ -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 可继续使用。
## 开通平台账号
## 开通平台账号
...
...
docs/qa/2026-10-12-platform-onboarding-local.md
0 → 100644
View file @
cb08cf0e
# 平台客户开户本地验证
日期: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 拒绝,不能将本次隔离数据库测试当作真实数据联调通过。
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment